Compare commits

...

23 Commits

Author SHA1 Message Date
truewhile 8458b8060a 优化 2026-10-30 10:45:59 +08:00
truewhile 623d87f12b 优化 2026-10-29 23:47:18 +08:00
truewhile d820214abc 优化 2026-10-29 22:52:39 +08:00
truewhile 0ab4693b92 优化 2026-10-29 22:11:28 +08:00
truewhile db516ded61 优化 2026-10-29 00:09:00 +08:00
truewhile 416d4c8c3b 优化 2026-10-27 22:14:40 +08:00
truewhile 78cfe0aba2 优化 2026-10-24 13:51:25 +08:00
truewhile 60ed34d5b1 优化 2026-10-24 00:22:10 +08:00
truewhile 31e775b9ba 优化 2026-10-24 00:12:20 +08:00
truewhile bf35bd3d02 优化 2026-10-23 23:11:05 +08:00
truewhile 0a84031d06 fix(player): 方向键不再唤出控制栏,仅续期隐藏计时器
方向键调整进度/音量时,若控制栏隐藏则保持隐藏,若可见则只刷新自动隐藏计时器,避免连续按键中途消失。
2026-10-22 17:11:02 +08:00
truewhile 9ccf60427a 优化 2026-10-22 15:14:00 +08:00
truewhile 54b0e33968 优化 2026-10-22 00:44:37 +08:00
truewhile 74487359bf 优 2026-10-21 13:21:13 +08:00
truewhile 55854a0adb 优化播放逻辑 2026-10-21 12:02:11 +08:00
truewhile 121a0a3cc5 优化 2026-10-21 10:11:47 +08:00
truewhile 3057664a48 优化 2026-10-21 09:19:14 +08:00
truewhile 7793d50906 优化 2026-10-21 00:06:16 +08:00
truewhile 5ad71b9421 优化查询 2026-10-20 23:29:51 +08:00
truewhile 5615806c2c Merge pull request #49 from truewhile/feat/media-maintenance-and-diagnostics
完善媒体维护、备份恢复与播放诊断
2026-10-20 22:04:10 +08:00
jason jia 1e8fe2396e feat: 完善媒体维护、备份恢复与播放诊断 2026-10-20 14:01:42 +00:00
truewhile e34e81cce0 Merge pull request #48 from truewhile/fix/media-access-cache-search
修复实时事件权限和媒体缓存隔离,移除大库列表截断并缓存排序索引,批量同步 OpenSearch 并处理逐项失败。
2026-10-20 20:40:49 +08:00
jason jia 3cf898756b fix media event access, cache isolation and indexing 2026-10-20 12:39:13 +00:00
188 changed files with 13979 additions and 1015 deletions
+11
View File
@@ -79,6 +79,9 @@ func (a *application) start() error {
}
}
if err := database.ApplyPendingSQLiteRestore(context.Background(), a.cfg, a.logger); err != nil {
return fmt.Errorf("apply pending database restore: %w", err)
}
db, err := database.Open(a.cfg, a.logger)
if err != nil {
a.logger.Error("database open failed", zap.Error(err))
@@ -110,6 +113,14 @@ func (a *application) start() error {
a.logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned))
}
// 一次性删除旧版解析误写的「整集片头/回顾」区间(end_ms <= start_ms),
// 否则升级后播放器仍会一开播就弹跳过按钮。
if cleaned, err := a.services.NormalizeImpossibleSegments(context.Background()); err != nil {
a.logger.Warn("impossible skip segment cleanup failed", zap.Error(err))
} else if cleaned > 0 {
a.logger.Info("impossible skip segments removed", zap.Int("rows", cleaned))
}
if err := a.services.Auth.SeedAdmin(context.Background()); err != nil {
a.logger.Warn("seed admin failed", zap.Error(err))
}
BIN
View File
Binary file not shown.
@@ -0,0 +1,46 @@
# Playback Correctness and Isolation Implementation Plan
**Goal:** Fix the five playback defects from the 2026-10-21 review and make playback capability queries side-effect-free, without replacing the player or adding dependencies.
**Scope:** The user approved implementing the reviewed fixes. Work in the current clean checkout; do not commit, push, deploy, or alter unrelated files.
## Design and invariants
- Keep existing media IDs, stream routes, direct playback and external-player URLs compatible.
- Add optional `session_id` to local HLS requests. Authenticated handlers supply the actual user identity; a client parameter never supplies the owner. New web playback sessions use random IDs. Legacy clients are isolated by user and, where available, Emby play-session/device identity.
- Keep transcoder media lookup/logging separate from its internal task key. A task key incorporates user, media and playback session. Each actual ffmpeg run writes to a fresh generation directory, so delayed segment requests never read a replacement run's output.
- Stop only the authenticated caller's playback session. Check media visibility and scoped playback tokens on stop, just as on playlist/segment requests. Segment and playlist queries must match the running generation.
- Cloud child requests resolve their real session media first, compare query media, enforce token scope and visibility against the real media, and reject mismatches before upstream fetch.
- Treat player business positions as absolute source seconds. Only local HLS converts to/from a relative ffmpeg timeline.
- Invalidate outstanding cloud-transcode requests on media/unmount, explicit source/quality changes, and newer requests. Guard both fulfilled and rejected responses.
- Select local fallbacks only from available local qualities. Keep the existing 115-first preference when a cloud quality is available.
- `PlaybackInfo` only reads capabilities/status. Explicit `StartTranscode` requests enqueue cloud work. Retain pending/ready response contracts, polling, and retries.
## Tasks
1. Add regression tests for local-scope isolation, stale generations, stop authorization, cloud media binding, pure capability reads, timeline conversion, available-local selection and stale async results. Run tests before implementation and verify the intended failures.
2. Add tested playback helpers; route skip/undo/progress/quality/subtitle calculations through the common absolute timeline; guard cloud transcode results and fix local fallback choice.
3. Extend existing transcoder/stream methods with an optional scope, preserve legacy internal calls, isolate task keys/output directories, and pass caller scope from web and Emby handlers. Bind web local HLS URLs and stop requests to captured session IDs.
4. Enforce real-media authorization on cloud child requests and authenticated ownership on local stop.
5. Split read-only cloud capability lookup from explicit cloud enqueue. Update existing cloud tests to use the explicit start operation and test no push during reads.
6. Run focused and full Go tests, frontend tests, TypeScript/Vite build, lint diagnostics and review the diff for security, lifecycle and API compatibility. Fix regressions before reporting completion.
## Verification commands
- `node --experimental-strip-types --test src/pages/hlsPlayback.test.ts src/pages/directPlaySeek.test.ts src/pages/playerPageModel.test.ts src/pages/playbackQuality.test.ts src/pages/playbackRequestGuard.test.ts src/utils/skipSegments.test.ts`
- `go test ./internal/service ./internal/handler ./internal/middleware -count=1`
- `go test ./...`
- `npm run build` and targeted ESLint for changed frontend files.
## Out of scope
New remux/audio-only ffmpeg pipelines, speculative browser codec policy changes, player replacement, broad UI refactors, performance improvement claims without measurements, and production deployment.
## Verification outcome
- Six frontend playback regression scripts pass; targeted ESLint and the TypeScript/Vite production build pass. The existing bundle-size warning remains.
- Playback/stream/transcode/progress tests in service, handler and middleware pass, including scoped output reads, stale generation rejection, scoped stops, device-to-segment propagation, read-only capability queries and concurrent cloud start deduplication.
- Whole-repository tests pass with only `TestQueueLibraryRootScanImportsNewLibraryMedia` and `TestSQLiteRestoreStagesWithoutChangingLiveDatabase` excluded. Both were run independently and reproduce Windows file-lock / SQLite errors in unmodified scan/database code. They are not silently disabled in source.
- FFmpeg is not installed in this environment, so the existing real-transcode lifecycle test skips. Actual video, upstream 115 and browser end-to-end playback remain unverified.
- Clients with no playback-session or device identifier retain per-user legacy isolation; new web requests always carry an independent session ID.
- No commit, push or deployment was performed.
+2 -3
View File
@@ -45,14 +45,13 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("logging.max_backups", 10)
v.SetDefault("cache.cache_dir", "./cache")
v.SetDefault("cache.images_max_size_mb", 2000)
v.SetDefault("cache.images_max_size_mb", 2048)
v.SetDefault("cache.images_originals_max_size_mb", 256)
v.SetDefault("cache.images_originals_ttl_hours", 168)
v.SetDefault("cache.memory_max_size_mb", DefaultCacheMemoryMaxSizeMB)
v.SetDefault("cache.redis_url", "")
v.SetDefault("cache.redis_prefix", "mebox")
v.SetDefault("cache.media_ttl_seconds", 90)
v.SetDefault("cache.emby_latest_ttl_seconds", 300)
v.SetDefault("cache.emby_latest_ttl_seconds", 900)
v.SetDefault("cache.reader_content_max_size_mb", 1024)
v.SetDefault("cache.reader_content_ttl_hours", 168)
v.SetDefault("cache.reader_files_ttl_hours", 720)
-3
View File
@@ -50,9 +50,6 @@ func (c *Config) normalize() error {
if c.Cache.ImagesOriginalsMaxSizeMB < 0 {
c.Cache.ImagesOriginalsMaxSizeMB = 0
}
if c.Cache.ImagesOriginalsTTLHours < 0 {
c.Cache.ImagesOriginalsTTLHours = 0
}
if c.Cache.MemoryMaxSizeMB <= 0 {
c.Cache.MemoryMaxSizeMB = DefaultCacheMemoryMaxSizeMB
}
+9 -6
View File
@@ -120,14 +120,16 @@ type LoggingConfig struct {
// CacheConfig 控制磁盘转写缓存和进程内热缓存。
type CacheConfig struct {
CacheDir string `mapstructure:"cache_dir"`
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
CacheDir string `mapstructure:"cache_dir"`
// ImagesMaxSizeMB 是整个磁盘缓存的总容量上限(MB),不是 images 子目录
// 的配额。参与统计的池为 hls、images、library-covers;超限时按该顺序淘汰
// 最旧文件。reader-audio 不在其中:它按 reader.audioMediaMaxBytes 的固定
// 配额(当前 1GiB)在写入时独立淘汰,不参与这里的总量。
// 0 表示不限制。
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
// ImagesOriginalsMaxSizeMB 限制 cache/images 下原图子配额(MB)。原图只是
// 生成缩略图的原料,丢了可以重新回源;0 表示不单独限制(只受总量约束)。
ImagesOriginalsMaxSizeMB int `mapstructure:"images_originals_max_size_mb"`
// ImagesOriginalsTTLHours 是原图的保留时长(小时),超过即删除;0 表示
// 不按时间淘汰。缩短它能让原图缓存保持在一个滚动窗口内。
ImagesOriginalsTTLHours int `mapstructure:"images_originals_ttl_hours"`
// MemoryMaxSizeMB 限制进程内 L1 缓存总字节数,JSON/对象缓存共用该预算。
MemoryMaxSizeMB int `mapstructure:"memory_max_size_mb"`
RedisURL string `mapstructure:"redis_url"`
@@ -136,7 +138,8 @@ type CacheConfig struct {
// EmbyLatestTTLSeconds 是 Emby「最新添加」(Items/Latest) 的缓存时长。
// 客户端刷新首页时会并发请求全部媒体库的 Latest(生产环境观察到 73 个
// 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机
// 上造成秒级延迟。默认 300 秒,新入库内容最迟 5 分钟后出现在最新列表。
// 上造成秒级延迟。默认 900 秒;过期后仍会先返回陈旧副本并在后台重建,
// 新入库内容最迟 15 分钟后出现在最新列表(扫描完成会主动失效缓存)。
EmbyLatestTTLSeconds int `mapstructure:"emby_latest_ttl_seconds"`
// ReaderContentMaxSizeMB 阅读正文持久缓存的总容量(MB),0 表示不限制。
ReaderContentMaxSizeMB int `mapstructure:"reader_content_max_size_mb"`
+5
View File
@@ -376,6 +376,11 @@ func ensurePerformanceIndexes(db *gorm.DB) error {
`DROP INDEX IF EXISTS idx_media_library_release_active`,
`CREATE INDEX IF NOT EXISTS idx_media_library_created_active ON media(library_id, created_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_episode_active ON media(library_id, season_num, episode_num, created_at DESC) WHERE deleted_at IS NULL`,
// 虚拟剧集分组索引:series_id 为空的剧集行要按
// (season_num, episode_num, episode_fraction, created_at) 排序后做内存
// 分组。没有它 SQLite 只能全表扫描再建临时 B-tree,而 Emby 的
// 卡片/海报查询每次只关心其中一部剧(生产环境实测单次 1.3s)。
`CREATE INDEX IF NOT EXISTS idx_media_virtual_series_group ON media(season_num, episode_num, episode_fraction, created_at) WHERE deleted_at IS NULL AND (series_id IS NULL OR series_id = '')`,
`CREATE INDEX IF NOT EXISTS idx_media_library_root_active ON media(library_id, library_root_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_series_active ON media(series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_series_active ON media(library_id, series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
+189
View File
@@ -0,0 +1,189 @@
package database
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
)
var sqliteRestoreMu sync.Mutex
func sqliteRestorePath(cfg *config.Config) (string, error) {
if cfg == nil || strings.TrimSpace(cfg.Database.DBPath) == "" || cfg.Database.DBPath == ":memory:" || strings.HasPrefix(cfg.Database.DBPath, "file:") {
return "", errors.New("恢复需要配置有效的 SQLite 数据库文件路径")
}
return filepath.Abs(cfg.Database.DBPath)
}
func sqliteRestoreDSN(path, mode string) string {
u := url.URL{Scheme: "file", Path: filepath.ToSlash(path)}
q := url.Values{"mode": {mode}, "_pragma": {"busy_timeout(5000)"}}
u.RawQuery = q.Encode()
return u.String()
}
func validateSQLiteRestore(ctx context.Context, path string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return errors.New("备份必须是常规文件")
}
db, err := sql.Open("sqlite", sqliteRestoreDSN(path, "ro"))
if err != nil {
return err
}
defer db.Close()
var result string
if err := db.QueryRowContext(ctx, "PRAGMA quick_check").Scan(&result); err != nil {
return fmt.Errorf("SQLite 备份校验失败: %w", err)
}
if result != "ok" {
return fmt.Errorf("SQLite 备份校验失败: %s", result)
}
var tables int
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('users', 'media', 'settings')").Scan(&tables); err != nil {
return err
}
if tables == 0 {
return errors.New("备份不包含 MeBox 数据表")
}
return nil
}
// StageSQLiteRestore leaves the live database and its WAL untouched. The
// validated copy is applied on the next startup, before any database is opened.
func StageSQLiteRestore(ctx context.Context, cfg *config.Config, source string) error {
sqliteRestoreMu.Lock()
defer sqliteRestoreMu.Unlock()
dbPath, err := sqliteRestorePath(cfg)
if err != nil {
return err
}
pending := dbPath + ".restore-pending"
if _, err := os.Lstat(pending); err == nil {
return errors.New("已有等待恢复的备份,请重启 MeBox 后再操作")
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := validateSQLiteRestore(ctx, source); err != nil {
return err
}
in, err := os.Open(source)
if err != nil {
return err
}
defer in.Close()
out, err := os.CreateTemp(filepath.Dir(dbPath), ".mebox-restore-*")
if err != nil {
return err
}
tmp := out.Name()
defer os.Remove(tmp)
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
if err := out.Sync(); err != nil {
_ = out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
if err := validateSQLiteRestore(ctx, tmp); err != nil {
return err
}
return os.Rename(tmp, pending)
}
// ApplyPendingSQLiteRestore must run before database.Open and before workers or
// HTTP listeners start. The previous database is retained alongside the file.
func ApplyPendingSQLiteRestore(ctx context.Context, cfg *config.Config, log *zap.Logger) error {
if cfg == nil {
return nil
}
dialect := normalizeDatabaseType(cfg.Database.Type)
if dialect == "auto" {
dialect = effectiveAutoDatabaseType(cfg)
}
if dialect != "sqlite" {
return nil
}
sqliteRestoreMu.Lock()
defer sqliteRestoreMu.Unlock()
dbPath, err := sqliteRestorePath(cfg)
if err != nil {
return err
}
pending := dbPath + ".restore-pending"
if _, err := os.Lstat(pending); errors.Is(err, os.ErrNotExist) {
return nil
} else if err != nil {
return err
}
if err := validateSQLiteRestore(ctx, pending); err != nil {
return err
}
previous := ""
if _, err := os.Lstat(dbPath); err == nil {
// A clean checkpoint prevents an old WAL from being replayed into the
// replacement database. A busy checkpoint leaves all files untouched.
live, err := sql.Open("sqlite", sqliteRestoreDSN(dbPath, "rw"))
if err != nil {
return err
}
var busy, pages, copied int
err = live.QueryRowContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &pages, &copied)
closeErr := live.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
if busy != 0 {
return errors.New("SQLite 数据库仍被占用,恢复已保留等待下次启动")
}
old, err := os.CreateTemp(filepath.Dir(dbPath), filepath.Base(dbPath)+".before-restore-*")
if err != nil {
return err
}
previous = old.Name()
if err := old.Close(); err != nil {
return err
}
if err := os.Remove(previous); err != nil {
return err
}
if err := os.Rename(dbPath, previous); err != nil {
return err
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.Rename(pending, dbPath); err != nil {
if previous != "" {
if rollbackErr := os.Rename(previous, dbPath); rollbackErr != nil {
return fmt.Errorf("恢复失败: %w;回退失败: %v,原数据库保留于 %s", err, rollbackErr, previous)
}
}
return err
}
if log != nil {
log.Warn("SQLite backup restored before startup", zap.String("previous_database", previous))
}
return nil
}
+104
View File
@@ -0,0 +1,104 @@
package database
import (
"database/sql"
"os"
"path/filepath"
"testing"
"github.com/truewhile/MeBox/internal/config"
)
func TestSQLiteRestoreStagesWithoutChangingLiveDatabase(t *testing.T) {
root := t.TempDir()
cfg := &config.Config{}
cfg.Database.Type = "sqlite"
cfg.Database.DBPath = filepath.Join(root, "live.db")
live, err := sql.Open("sqlite", sqliteRestoreDSN(cfg.Database.DBPath, "rwc"))
if err != nil {
t.Fatal(err)
}
defer live.Close()
for _, statement := range []string{
"PRAGMA journal_mode=WAL", "CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)",
"INSERT INTO settings VALUES ('state', 'backup')",
} {
if _, err := live.Exec(statement); err != nil {
t.Fatal(err)
}
}
source := filepath.Join(root, "backup.db")
if _, err := live.Exec("VACUUM INTO ?", source); err != nil {
t.Fatal(err)
}
if _, err := live.Exec("UPDATE settings SET value = 'live'"); err != nil {
t.Fatal(err)
}
if err := StageSQLiteRestore(t.Context(), cfg, source); err != nil {
t.Fatal(err)
}
var value string
if err := live.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "live" {
t.Fatalf("staging changed live database: value=%q err=%v", value, err)
}
if _, err := live.Exec("UPDATE settings SET value = 'after-stage'"); err != nil {
t.Fatalf("database could not write after staging: %v", err)
}
if err := StageSQLiteRestore(t.Context(), cfg, source); err == nil {
t.Fatal("second staging should not replace the pending restore")
}
if err := os.Remove(source); err != nil {
t.Fatal(err)
}
if err := live.Close(); err != nil {
t.Fatal(err)
}
if err := ApplyPendingSQLiteRestore(t.Context(), cfg, nil); err != nil {
t.Fatal(err)
}
restored, err := sql.Open("sqlite", sqliteRestoreDSN(cfg.Database.DBPath, "ro"))
if err != nil {
t.Fatal(err)
}
defer restored.Close()
if err := restored.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "backup" {
t.Fatalf("restore failed: value=%q err=%v", value, err)
}
previous, err := filepath.Glob(cfg.Database.DBPath + ".before-restore-*")
if err != nil || len(previous) != 1 {
t.Fatalf("previous database not preserved: files=%v err=%v", previous, err)
}
old, err := sql.Open("sqlite", sqliteRestoreDSN(previous[0], "ro"))
if err != nil {
t.Fatal(err)
}
defer old.Close()
if err := old.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "after-stage" {
t.Fatalf("old database lost latest writes: value=%q err=%v", value, err)
}
if err := ApplyPendingSQLiteRestore(t.Context(), cfg, nil); err != nil {
t.Fatalf("repeat startup: %v", err)
}
}
func TestSQLiteRestoreRejectsCorruptAndSymlinkBackups(t *testing.T) {
root := t.TempDir()
cfg := &config.Config{}
cfg.Database.DBPath = filepath.Join(root, "live.db")
source := filepath.Join(root, "corrupt.db")
if err := os.WriteFile(source, []byte("not a SQLite database"), 0o600); err != nil {
t.Fatal(err)
}
if err := StageSQLiteRestore(t.Context(), cfg, source); err == nil {
t.Fatal("corrupt backup accepted")
}
link := filepath.Join(root, "link.db")
if err := os.Symlink(source, link); err == nil {
if err := StageSQLiteRestore(t.Context(), cfg, link); err == nil {
t.Fatal("symlink backup accepted")
}
}
if _, err := os.Stat(cfg.Database.DBPath + ".restore-pending"); !os.IsNotExist(err) {
t.Fatalf("failed validation left pending data: %v", err)
}
}
+2 -2
View File
@@ -87,9 +87,9 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
if req.Key == "transcode.hw_enabled" || req.Key == "transcode.hw_accel" || req.Key == "transcoder.hardware_accel" || req.Key == "transcoder.encoder" {
svc.Transcoder.StopAll()
}
if req.Key == "cache.images_max_size_mb" || req.Key == "cache.images_originals_max_size_mb" || req.Key == "cache.images_originals_ttl_hours" {
if req.Key == "cache.images_max_size_mb" || req.Key == "cache.images_originals_max_size_mb" {
if svc.Scheduler != nil {
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup")
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "cache_cleanup")
}
}
if req.Key == "cache.memory_max_size_mb" && svc.Cache != nil {
+18
View File
@@ -0,0 +1,18 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
func backgroundTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Tasks == nil {
c.JSON(http.StatusOK, service.TaskSnapshot{Active: []service.BackgroundTask{}, Recent: []service.BackgroundTask{}})
return
}
c.JSON(http.StatusOK, svc.Tasks.Snapshot())
}
}
+18 -1
View File
@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
@@ -41,12 +42,28 @@ func deleteBackupHandler(svc *service.Container) gin.HandlerFunc {
}
}
func downloadBackupHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
filename := c.Query("filename")
path, err := svc.Backup.ResolvePath(filename)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.FileAttachment(path, filepath.Base(path))
}
}
func restoreBackupHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Backup.Restore(c.Request.Context(), c.Query("filename")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "restored — restart the server to apply"})
message := "数据库已恢复,请重启 MeBox 以刷新运行时配置"
if svc.Repo.DB.Dialector.Name() == "sqlite" {
message = "备份已校验并暂存,请重启 MeBox 后应用恢复"
}
c.JSON(http.StatusOK, gin.H{"message": message, "restart_required": true})
}
}
+24 -2
View File
@@ -124,10 +124,32 @@ func cloud115HLSSessionHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing media_id"})
return
}
if !enforceScopedPlaybackToken(c, mediaID) {
if c.Request.Context().Err() != nil {
c.AbortWithStatus(499)
return
}
err := svc.Cloud115.HLSProxy().ServeChild(
actualMediaID, err := svc.Cloud115.HLSProxy().SessionMediaID(c.Param("session"), c.Param("key"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"code": "hls_session_not_found", "error": "hls session not found"})
return
}
if mediaID != actualMediaID {
c.JSON(http.StatusForbidden, gin.H{"error": "playback session media mismatch"})
return
}
if !enforceScopedPlaybackToken(c, actualMediaID) {
return
}
if svc.Repo == nil || svc.Repo.Media == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
m, findErr := svc.Repo.Media.FindByID(c.Request.Context(), actualMediaID)
if findErr != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
err = svc.Cloud115.HLSProxy().ServeChild(
c.Request.Context(),
c.Writer,
c.Request,
+113
View File
@@ -0,0 +1,113 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 客户端刷新首页时会用 IncludeItemTypes=Movie,Series,Video 一次取「最新」行。
// 电影库必须返回电影,而不是因为请求里带了 Series 就整行判空。
func TestEmbyItemsMixedTypesKeepsMoviesInMovieLibrary(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
if err := svc.Repo.DB.AutoMigrate(&model.Series{}, &model.PlayProfile{}); err != nil {
t.Fatalf("migrate: %v", err)
}
libID := seedEmbyLibrary(t, svc, "movie")
if err := svc.Repo.DB.WithContext(context.Background()).Create(&model.Media{
Base: model.Base{ID: "movie-1"},
LibraryID: libID,
Title: "Movie One",
Path: "/media/movie/one.mp4",
Year: 2024,
ReleaseDate: "2024-01-01",
}).Error; err != nil {
t.Fatalf("create movie: %v", err)
}
path := "/emby/Items?ParentId=" + libID +
"&IncludeItemTypes=Movie,Series,Video&Recursive=true" +
"&SortBy=DateCreated,SortName&SortOrder=Descending&Limit=12&StartIndex=0"
w := embyGet(t, router, path, signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 1 {
t.Fatalf("expected the movie to be returned, got %d items: %s", len(items), w.Body.String())
}
if items[0]["Id"] != "movie-1" || items[0]["Type"] != "Movie" {
t.Fatalf("unexpected item payload: %#v", items[0])
}
}
// 同一组混合类型在剧集库上仍要折叠成剧集卡片,修复不能把这条路径弄回单集。
func TestEmbyItemsMixedTypesKeepsSeriesCardsInTvLibrary(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
if err := svc.Repo.DB.AutoMigrate(&model.Series{}, &model.PlayProfile{}); err != nil {
t.Fatalf("migrate: %v", err)
}
libID := seedEmbyLibrary(t, svc, "tv")
for _, ep := range []struct {
id string
num int
path string
}{
{id: "ep-1", num: 1, path: "/media/tv/show/S01E01.mkv"},
{id: "ep-2", num: 2, path: "/media/tv/show/S01E02.mkv"},
} {
if err := svc.Repo.DB.WithContext(context.Background()).Create(&model.Media{
Base: model.Base{ID: ep.id},
LibraryID: libID,
SeriesID: "series-1",
Title: "Show",
Path: ep.path,
SeasonNum: 1,
EpisodeNum: ep.num,
Year: 2024,
}).Error; err != nil {
t.Fatalf("create episode: %v", err)
}
}
path := "/emby/Items?ParentId=" + libID +
"&IncludeItemTypes=Movie,Series,Video&Recursive=true" +
"&SortBy=DateCreated,SortName&SortOrder=Descending&Limit=12&StartIndex=0"
w := embyGet(t, router, path, signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 1 {
t.Fatalf("expected one series card, got %d items: %s", len(items), w.Body.String())
}
if items[0]["Type"] != "Series" {
t.Fatalf("expected Series card, got %#v", items[0])
}
}
// Emby 的 SpecialFeatures 响应是 BaseItemDto[],客户端按 List 强转;
// 返回 {"Items":[]} 会直接抛 type cast 异常。
func TestEmbySpecialFeaturesReturnsBareArray(t *testing.T) {
router, _, _ := newEmbyDiscoveryEnv(t)
w := embyGet(t, router, "/emby/Items/movie-1/SpecialFeatures", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
if trimmed := bytes.TrimSpace(w.Body.Bytes()); len(trimmed) == 0 || trimmed[0] != '[' {
t.Fatalf("SpecialFeatures must be a JSON array, got %s", w.Body.String())
}
var items []any
if err := json.Unmarshal(w.Body.Bytes(), &items); err != nil {
t.Fatalf("decode array: %v body=%s", err, w.Body.String())
}
if len(items) != 0 {
t.Fatalf("expected empty array, got %d entries", len(items))
}
}
+19
View File
@@ -50,6 +50,25 @@ func embyServeImage(c *gin.Context, svc *service.Container, id, imageType, tag s
embyServePlaceholderImage(c)
return
}
// 媒体库主图:按 Emby 的做法,用库内几张成员海报拼一张封面(folder
// image)。没有自定义封面时才生成;自定义封面继续走下面的代理路径。
if svc.ImageProxy != nil && strings.EqualFold(strings.TrimSpace(imageType), "Primary") {
if sources, lib := service.LibraryCoverSourcesForLibrary(ctx, svc.Repo, id); lib != nil &&
strings.TrimSpace(lib.CoverURL) == "" && len(sources) > 0 {
width, height := libraryCoverRequestSize(c)
if data, err := svc.ImageProxy.LibraryCoverImage(ctx, id, sources, width, height); err == nil && len(data) > 0 {
c.Header("Cache-Control", "public, max-age=300, stale-while-revalidate=86400")
c.Header("Content-Type", "image/jpeg")
c.Header("Content-Length", strconv.Itoa(len(data)))
if c.Request.Method == http.MethodHead {
c.Status(http.StatusOK)
return
}
c.Data(http.StatusOK, "image/jpeg", data)
return
}
}
}
// PersonImageURL handles TMDb/remote people first and falls back to regular
// media/library artwork, so the official /Items/{personId}/Images route
// works for synthetic person IDs as well as normal item IDs.
+6
View File
@@ -86,6 +86,12 @@ func embySegmentItems(m *model.Media, rows []model.MediaSegment, want map[string
}
endMs := row.EndMs
if endMs <= 0 {
// 只有片尾类允许「延续到片尾」。片头/回顾的结束点缺失意味着这段区间
// 本身不可信(数据源用 0 表示「这段不存在」),补成片长会让客户端把
// 整集标成片头,所以直接丢弃。
if row.Kind != model.SegmentKindCredits && row.Kind != model.SegmentKindPreview {
continue
}
if durationMs <= 0 {
continue
}
+10 -2
View File
@@ -418,7 +418,11 @@ func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusNotFound)
return
}
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"))
scope, ok := localHLSPlaybackScope(c)
if !ok {
return
}
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"), scope)
if errors.Is(err, service.ErrTranscodeDisabled) {
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
return
@@ -444,7 +448,11 @@ func embyVideoHLSSegmentHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusNotFound)
return
}
if err := svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg")); err != nil {
scope, ok := localHLSPlaybackScope(c)
if !ok {
return
}
if err := svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg"), scope); err != nil {
c.Status(http.StatusNotFound)
}
}
+4 -2
View File
@@ -212,9 +212,11 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
auth.GET("/Items/:id/Similar", embySimilarHandler(svc))
auth.GET("/Items/:id/ThumbnailSet", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/ThemeMedia", embyThemeMediaHandler(svc))
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
// SpecialFeatures 在 Emby 官方 schema 里是 BaseItemDto[](裸数组),
// 返回 {"Items":[]} 会让按 List 强转的客户端抛 type cast 异常。
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyArrayHandler(svc))
auth.GET("/Users/:userId/Items/:id/Intros", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/SpecialFeatures", embyEmptyArrayHandler(svc))
auth.GET("/Items/:id/Intros", embyEmptyItemsHandler(svc))
auth.GET("/api/danmu/:id/raw", embyDanmuRawHandler(svc))
}
+2 -2
View File
@@ -55,9 +55,9 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
auth.GET("/items/:id/similar", embySimilarHandler(svc))
auth.GET("/items/:id/thumbnailset", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/thememedia", embyThemeMediaHandler(svc))
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyArrayHandler(svc))
auth.GET("/users/:userId/items/:id/intros", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/specialfeatures", embyEmptyArrayHandler(svc))
auth.GET("/items/:id/intros", embyEmptyItemsHandler(svc))
}
+37
View File
@@ -0,0 +1,37 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
func TestStopHLSChecksMediaVisibility(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
svc.Transcoder = service.NewTranscoderService(svc.Cfg, zap.NewNop(), svc.Repo, nil)
router.DELETE("/api/hls/:id", middleware.AuthRequired(secret), stopTranscodeHandler(svc))
req := httptest.NewRequest(http.MethodDelete, "/api/hls/nonexistent?session_id=player-a", nil)
req.Header.Set("Authorization", "Bearer "+signedTestToken(t, secret))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("stop of a nonexistent/invisible media: status=%d body=%s, want 404", rec.Code, rec.Body.String())
}
profile := &model.PlayProfile{UserID: "user-1", Name: "restricted", IsDefault: true, AllowedLibraryIDs: `["other-library"]`}
if err := svc.Repo.DB.Create(profile).Error; err != nil {
t.Fatal(err)
}
req = httptest.NewRequest(http.MethodDelete, "/api/hls/media-1?session_id=player-a", nil)
req.Header.Set("Authorization", "Bearer "+signedTestToken(t, secret))
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("invisible existing media can be stopped: %d %s", rec.Code, rec.Body.String())
}
}
+100
View File
@@ -0,0 +1,100 @@
package handler
import (
"context"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
// libraryCoverHandler 输出媒体库封面图(Emby 风格的成员海报拼图)。
//
// 之前 Web 端是拿两张成员海报在前端用 CSS 拼格子,Emby 客户端则直接拿一张
// 评分最高的海报,结果同一个库在不同客户端长得完全不一样。现在服务端生成
// 一张 16:9 的拼图(Emby 的 folder image 做法),Emby 客户端、Web 和 App
// 都指向这一份,外观统一。
func libraryCoverHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if id == "" || svc == nil || svc.ImageProxy == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "cover unavailable"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 25*time.Second)
defer cancel()
sources, lib := service.LibraryCoverSourcesForLibrary(ctx, svc.Repo, id)
if lib == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "library not found"})
return
}
if strings.TrimSpace(lib.CoverURL) != "" {
// 管理员自定义封面优先,保持既有行为(代理该地址)。
if err := svc.ImageProxy.Serve(ctx, c.Writer, c.Request, lib.CoverURL); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "custom cover unavailable"})
}
return
}
if len(sources) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no library items with artwork"})
return
}
width, height := libraryCoverRequestSize(c)
data, err := svc.ImageProxy.LibraryCoverImage(ctx, id, sources, width, height)
if err != nil || len(data) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "cover generation failed"})
return
}
writeLibraryCoverImage(c, data, http.StatusOK)
}
}
// libraryCoverRequestSize 解析请求的封面尺寸:Web 用 w/h,Emby 用
// maxWidth/maxHeight,两种都兼容。服务端统一输出 16:9 画布(与该库在 Emby
// 里上报的 PrimaryImageAspectRatio 一致),客户端传的尺寸只当作「不超过」的
// 边界使用。
func libraryCoverRequestSize(c *gin.Context) (int, int) {
return firstPositiveQueryIntParam(c, "maxWidth", "maxwidth", "width", "w"),
firstPositiveQueryIntParam(c, "maxHeight", "maxheight", "height", "h")
}
func firstPositiveQueryIntParam(c *gin.Context, names ...string) int {
for _, name := range names {
raw := strings.TrimSpace(c.Query(name))
if raw == "" {
continue
}
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
return n
}
if f, err := strconv.ParseFloat(raw, 64); err == nil && f > 0 {
return int(f)
}
}
return 0
}
func writeLibraryCoverImage(c *gin.Context, data []byte, status int) {
sum := sha256.Sum256(data)
etag := `"` + hex.EncodeToString(sum[:8]) + `"`
c.Header("ETag", etag)
c.Header("Cache-Control", "public, max-age=300, stale-while-revalidate=86400")
if match := strings.TrimSpace(c.GetHeader("If-None-Match")); match != "" && match == etag {
c.Status(http.StatusNotModified)
return
}
c.Header("Content-Type", "image/jpeg")
c.Header("Content-Length", strconv.Itoa(len(data)))
if c.Request.Method == http.MethodHead {
c.Status(status)
return
}
c.Data(status, "image/jpeg", data)
}
@@ -0,0 +1,118 @@
package handler
import (
"bytes"
"image/color"
"image/jpeg"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"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 coverTestToken(t *testing.T, secret, userID string) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, middleware.Claims{
UserID: userID,
Role: "admin",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: userID,
},
})
signed, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("sign token: %v", err)
}
return signed
}
// TestLibraryCoverRouteServesImageThroughFullRouter 走完整的 handler.Register
// 路由表请求封面:既验证路由挂载正确,也验证鉴权链(AuthRequired +
// activeUserRequired)不会把它挡成 404。
func TestLibraryCoverRouteServesImageThroughFullRouter(t *testing.T) {
gin.SetMode(gin.TestMode)
const secret = "test-secret"
poster := coverTestJPEG(t, color.RGBA{R: 200, G: 120, B: 60, A: 255})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(poster)
}))
defer upstream.Close()
parsed, _ := url.Parse(upstream.URL)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
user := model.User{Base: model.Base{ID: "user-1"}, Username: "viewer", IsActive: true}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for i := 0; i < 4; i++ {
row := model.Media{
Base: model.Base{ID: "m" + string(rune('a'+i))},
LibraryID: lib.ID,
Title: "Movie " + string(rune('A'+i)),
Path: "/media/movies/m" + string(rune('a'+i)) + ".mkv",
PosterURL: upstream.URL + "/p" + string(rune('1'+i)) + ".jpg",
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
cfg := &config.Config{
Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")},
Secrets: config.SecretsConfig{JWTSecret: secret},
}
proxy := service.NewImageProxy(cfg, zap.NewNop())
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} })
svc := &service.Container{Repo: repos, ImageProxy: proxy}
engine := gin.New()
Register(engine, cfg, zap.NewNop(), svc)
token := coverTestToken(t, secret, user.ID)
req := httptest.NewRequest(http.MethodGet, "/api/libraries/"+lib.ID+"/cover?w=480&h=270&api_key="+token, nil)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Fatalf("content type = %q, want image/jpeg", ct)
}
img, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes()))
if err != nil {
t.Fatalf("decode cover: %v", err)
}
if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 270 {
t.Fatalf("cover size = %v, want 480x270", img.Bounds())
}
}
+228
View File
@@ -0,0 +1,228 @@
package handler
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func coverTestJPEG(t *testing.T, c color.RGBA) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 160, 240))
for y := 0; y < 240; y++ {
for x := 0; x < 160; x++ {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
return buf.Bytes()
}
// newLibraryCoverTestRouter 构造一个带真实图片代理的路由:成员海报来自
// 本地 httptest 服务,走的是与生产一致的 ImageProxy 抓取路径。
func newLibraryCoverTestRouter(t *testing.T) (*gin.Engine, *repository.Container, *httptest.Server) {
t.Helper()
gin.SetMode(gin.TestMode)
poster := coverTestJPEG(t, color.RGBA{R: 200, G: 120, B: 60, A: 255})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(poster)
}))
t.Cleanup(upstream.Close)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
parsed, err := url.Parse(upstream.URL)
if err != nil {
t.Fatalf("parse upstream: %v", err)
}
cfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}
proxy := service.NewImageProxy(cfg, zap.NewNop())
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} })
svc := &service.Container{Repo: repos, ImageProxy: proxy}
router := gin.New()
router.GET("/libraries/:id/cover", libraryCoverHandler(svc))
router.HEAD("/libraries/:id/cover", libraryCoverHandler(svc))
return router, repos, upstream
}
func seedCoverLibrary(t *testing.T, repos *repository.Container, posterBase string, coverURL string) model.Library {
t.Helper()
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true, CoverURL: coverURL}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for i := 0; i < 4; i++ {
row := model.Media{
Base: model.Base{ID: "m" + string(rune('a'+i))},
LibraryID: lib.ID,
Title: "Movie " + string(rune('A'+i)),
Path: "/media/movies/m" + string(rune('a'+i)) + ".mkv",
PosterURL: posterBase + "/p" + string(rune('1'+i)) + ".jpg",
}
if err := repos.DB.Create(&row).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
return lib
}
// TestLibraryCoverHandlerServesGeneratedCollage 覆盖 Web/App 的封面接口:
// 返回的是服务端拼好的 16:9 图片,而不是单张成员海报。
func TestLibraryCoverHandlerServesGeneratedCollage(t *testing.T) {
router, repos, upstream := newLibraryCoverTestRouter(t)
lib := seedCoverLibrary(t, repos, upstream.URL, "")
req := httptest.NewRequest(http.MethodGet, "/libraries/"+lib.ID+"/cover?w=480&h=270", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Fatalf("content type = %q, want image/jpeg", ct)
}
img, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes()))
if err != nil {
t.Fatalf("decode cover: %v", err)
}
if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 270 {
t.Fatalf("cover size = %v, want 480x270", img.Bounds())
}
if etag := w.Header().Get("ETag"); etag == "" {
t.Fatal("expected an ETag so clients can revalidate")
}
// 条件请求应拿到 304。
req = httptest.NewRequest(http.MethodGet, "/libraries/"+lib.ID+"/cover?w=480&h=270", nil)
req.Header.Set("If-None-Match", w.Header().Get("ETag"))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotModified {
t.Fatalf("conditional request status = %d, want 304", rec.Code)
}
}
func TestLibraryCoverHandlerUnknownLibraryReturns404(t *testing.T) {
router, _, _ := newLibraryCoverTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/libraries/missing/cover", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
}
// TestLibraryCoverHandlerServesCustomCover 管理员填了 cover_url 时必须继续
// 用自定义封面,不能在不知不觉中换成拼图。
func TestLibraryCoverHandlerServesCustomCover(t *testing.T) {
router, repos, upstream := newLibraryCoverTestRouter(t)
lib := seedCoverLibrary(t, repos, upstream.URL, upstream.URL+"/custom.jpg")
req := httptest.NewRequest(http.MethodGet, "/libraries/"+lib.ID+"/cover", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if _, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes())); err != nil {
t.Fatalf("custom cover should be an image: %v", err)
}
}
// TestEmbyLibraryPrimaryUsesGeneratedCollage 保证 Emby 客户端请求库主图时
// 拿到的也是拼图(与 Web/App 同一份生成逻辑)。
func TestEmbyLibraryPrimaryUsesGeneratedCollage(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
t.Fatalf("migrate: %v", err)
}
poster := coverTestJPEG(t, color.RGBA{G: 180, R: 90, B: 60, A: 255})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(poster)
}))
defer upstream.Close()
parsed, _ := url.Parse(upstream.URL)
repos := repository.New(db)
lib := model.Library{Name: "剧集", Path: "/media/tv", Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for i := 0; i < 4; i++ {
row := model.Media{
Base: model.Base{ID: "e" + string(rune('a'+i))},
LibraryID: lib.ID,
Title: "Show " + string(rune('A'+i)),
Path: "/media/tv/show" + string(rune('a'+i)) + "/S01E01.mkv",
SeasonNum: 1, EpisodeNum: 1,
PosterURL: upstream.URL + "/p" + string(rune('1'+i)) + ".jpg",
}
if err := repos.DB.Create(&row).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
cfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}
proxy := service.NewImageProxy(cfg, zap.NewNop())
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} })
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(cfg, zap.NewNop(), repos),
ImageProxy: proxy,
}
router := gin.New()
registerEmbyRoutes(router, "test-secret", svc)
req := httptest.NewRequest(http.MethodGet, "/Items/"+lib.ID+"/Images/Primary?maxWidth=400&maxHeight=300", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
img, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes()))
if err != nil {
t.Fatalf("decode emby library cover: %v", err)
}
// 请求的是 400×300 的盒子,拼图按 16:9 装进去 → 400×225。
if img.Bounds().Dx() != 400 || img.Bounds().Dy() != 225 {
t.Fatalf("cover size = %v, want 400x225 (16:9 inside the requested box)", img.Bounds())
}
}
+284 -119
View File
@@ -4,8 +4,11 @@ package handler
import (
"context"
"errors"
"fmt"
"math"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
@@ -38,8 +41,32 @@ type webLibraryPayload struct {
RemoteSource string `json:"remote_source,omitempty"`
Total int64 `json:"total,omitempty"`
Cards []service.SeriesCardView `json:"cards,omitempty"`
// CoverImageURL 是客户端可直接使用的「有效封面」:管理员自定义的
// cover_url 优先,否则指向服务端生成的成员海报拼图接口。Web / App 都
// 读这一个字段,避免各自用卡片在前端拼格子、长得还不一样。
CoverImageURL string `json:"cover_image_url,omitempty"`
}
// effectiveLibraryCoverURL 计算库封面的有效地址。远程挂载库的封面由上游
// Emby 决定,这里返回空串,调用方继续用卡片拼图兜底。
func effectiveLibraryCoverURL(lib model.Library, isRemote bool) string {
if isRemote {
return ""
}
if raw := strings.TrimSpace(lib.CoverURL); raw != "" {
return raw
}
if strings.TrimSpace(lib.ID) == "" {
return ""
}
return "/api/libraries/" + url.PathEscape(lib.ID) + "/cover"
}
// remoteLibraryPreviewBudget 限制 /api/libraries?with_preview=1 里远程 Emby
// 库预览(总条数 + 最新卡片)的总耗时。这些预览只用于首页卡片,值得为它
// 排队等待的量很小;此前它跟随请求上下文,上游超时会让整页等 15 秒以上。
var remoteLibraryPreviewBudget = 3 * time.Second
// remoteLibraryItemTypes 远程库内容拉取时按 CollectionType 过滤直属条目,
// 避免电影库里的合集文件夹(Folder) 漏出为电影卡片。
func remoteLibraryItemTypes(collectionType string) string {
@@ -117,9 +144,10 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
}
for _, p := range previews {
out = append(out, webLibraryPayload{
Library: p.Library,
Total: p.Total,
Cards: service.NewSeriesCardViews(p.Cards),
Library: p.Library,
Total: p.Total,
Cards: service.NewSeriesCardViews(p.Cards),
CoverImageURL: effectiveLibraryCoverURL(p.Library, false),
})
}
} else {
@@ -138,7 +166,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
if counts != nil {
total = counts[l.ID]
}
out = append(out, webLibraryPayload{Library: l, Total: total})
out = append(out, webLibraryPayload{Library: l, Total: total, CoverImageURL: effectiveLibraryCoverURL(l, false)})
}
}
// 精确指定目标库时,如果目标全是本地库,就不必枚举远程挂载。
@@ -176,6 +204,12 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
}
if withPreview && len(allowedViews) > 0 {
// 远程库预览只是首页卡片上的「最新几条」,绝不能拖住整个
// 库列表:上游 Emby 一旦超时,每个远程库都要等满
// embyRemoteHTTPTimeout(15s),串起来就是 17–30 秒。
// 给整段预览一个总预算,超时就只返回库名,卡片留给下一次刷新。
previewCtx, cancelPreview := context.WithTimeout(ctx, remoteLibraryPreviewBudget)
defer cancelPreview()
const maxRemotePreviewWorkers = 6
sem := make(chan struct{}, maxRemotePreviewWorkers)
var wg sync.WaitGroup
@@ -187,11 +221,11 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
case <-previewCtx.Done():
return
}
helper.Run(svc.Log, "media.remotePreview", func() {
acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID)
acct := svc.EmbyRemote.AccountByID(previewCtx, v.AccountID)
if acct == nil {
return
}
@@ -203,10 +237,10 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
Name: v.Library.Name,
}
itemTypes := remoteLibraryItemTypes(v.CollectionType)
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(previewCtx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
remotePayloads[i].Total = total
}
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
if cards, err := svc.EmbyRemote.RemoteLatestCards(previewCtx, tmpMount, acct, v.RemoteID, limit); err == nil {
remotePayloads[i].Cards = service.NewSeriesCardViews(cards)
}
})
@@ -259,9 +293,9 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, webLibraryPayload{Library: libs[0]})
c.JSON(http.StatusOK, webLibraryPayload{Library: libs[0], CoverImageURL: effectiveLibraryCoverURL(libs[0], false)})
} else {
c.JSON(http.StatusOK, webLibraryPayload{Library: *lib})
c.JSON(http.StatusOK, webLibraryPayload{Library: *lib, CoverImageURL: effectiveLibraryCoverURL(*lib, false)})
}
}
}
@@ -556,19 +590,17 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
})
return
}
grouped, err := svc.Media.GroupedMediaVisibleFiltered(ctx, id, mediaVisibilityForRequest(c, svc), filters)
items, total, err := svc.Media.ListMediaVisibleSortedPage(ctx, id, page, size, mediaVisibilityForRequest(c, svc), filters, sortSpec, history)
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": len(grouped),
"total": total,
"page": page,
"page_size": size,
})
@@ -656,6 +688,9 @@ func paginateSlice[T any](items []T, page, size int) []T {
if len(items) == 0 {
return []T{}
}
if page-1 > len(items)/size {
return []T{}
}
start := (page - 1) * size
if start >= len(items) {
return []T{}
@@ -670,127 +705,257 @@ func paginateSlice[T any](items []T, page, size int) []T {
func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
q := c.Query("q")
query := strings.TrimSpace(c.Query("q"))
visibility := mediaVisibilityForRequest(c, svc)
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
fetchRemote := func(limit int) []model.Media {
if svc.EmbyRemote == nil || strings.TrimSpace(q) == "" {
return nil
}
remoteItems, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, q, limit, visibility)
return remoteItems
filter := service.MediaSearchFilter{
LibraryID: strings.TrimSpace(c.Query("library_id")),
YearMin: boundedSearchYear(c.Query("year_min")),
YearMax: boundedSearchYear(c.Query("year_max")),
}
if c.DefaultQuery("group_series", "0") != "0" {
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
for _, genre := range c.QueryArray("genre") {
if genre = strings.TrimSpace(genre); genre != "" && len(filter.Genres) < 8 {
filter.Genres = append(filter.Genres, genre)
}
remoteItems := fetchRemote(50000)
all := service.GroupMediaSeriesItems(append(localItems, remoteItems...))
if c.Query("page") != "" || c.Query("page_size") != "" {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
paged := paginateSlice(all, page, size)
c.JSON(http.StatusOK, gin.H{
"items": paged,
"total": len(all),
"page": page,
"page_size": size,
})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 {
limit = 50
}
if len(all) > limit {
all = all[:limit]
}
c.JSON(http.StatusOK, gin.H{"items": all})
return
}
if c.Query("page") != "" || c.Query("page_size") != "" {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
if !groupVersions {
localItems, _, err := svc.Media.SearchMediaVisiblePage(ctx, q, 1, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(size * 2)
all := append(localItems, remoteItems...)
paged := paginateSlice(all, page, size)
c.JSON(http.StatusOK, gin.H{
"items": paged,
"total": len(all),
"page": page,
"page_size": size,
})
return
}
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(size * 2)
all := append(localItems, remoteItems...)
grouped := service.GroupMediaVersions(all)
paged := service.PaginateMediaItems(grouped, page, size)
c.JSON(http.StatusOK, gin.H{
"items": paged,
"total": len(grouped),
"page": page,
"page_size": size,
})
return
if filter.YearMin > 0 && filter.YearMax > 0 && filter.YearMin > filter.YearMax {
filter.YearMin, filter.YearMax = filter.YearMax, filter.YearMin
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
if size < 1 {
size = 50
} else if size > 2000 {
size = 2000
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 {
if limit < 1 {
limit = 50
} else if limit > 50000 {
limit = 50000
}
if !groupVersions {
localItems, err := svc.Media.SearchMediaVisible(ctx, q, limit, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(limit)
all := append(localItems, remoteItems...)
if len(all) > limit {
all = all[:limit]
}
if all == nil {
all = []model.Media{}
}
c.JSON(http.StatusOK, gin.H{"items": all})
return
}
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
paginated := c.Query("page") != "" || c.Query("page_size") != ""
sortBy := normalizeMediaSearchSort(c.Query("sort"))
// 远程搜索与本地查询并行发起:本地结果不再排在远程超时之后才返回。
remoteCh := startRemoteMediaSearch(ctx, svc, query, visibility, filter)
// Sorting and grouping must operate on the same candidate set before
// either a page size or the caller's output limit is applied.
items, err := svc.Media.SearchMediaVisibleFiltered(ctx, query, 50000, visibility, filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(limit)
all := append(localItems, remoteItems...)
grouped := service.GroupMediaVersions(all)
items := service.FirstMediaItems(grouped, limit)
if items == nil {
items = []service.MediaItem{}
if remoteCh != nil {
for _, item := range <-remoteCh {
items = append(items, item)
}
}
c.JSON(http.StatusOK, gin.H{"items": items})
if c.DefaultQuery("group_series", "0") != "0" {
items = service.GroupMediaSeriesItems(items)
sortSearchMedia(items, sortBy)
writeMediaSearchResults(c, items, page, size, limit, paginated)
return
}
if c.DefaultQuery("group_versions", "1") != "0" {
grouped := service.GroupMediaVersions(items)
if sortBy != "" {
sort.SliceStable(grouped, func(i, j int) bool {
return mediaSearchSortLess(grouped[i].Media, grouped[j].Media, sortBy)
})
}
writeMediaSearchResults(c, grouped, page, size, limit, paginated)
return
}
sortSearchMedia(items, sortBy)
writeMediaSearchResults(c, items, page, size, limit, paginated)
}
}
const (
// remoteSearchItemLimit 是每个远程 Emby 挂载点返回的条目上限。此前这里传
// 50000(等于让上游做一次全库查询),上游稍慢就会一路跑到 5 秒超时,把每次
// 搜索都拖成秒级;搜索场景只需要少量候选,多出来的结果也会被分页丢掉。
remoteSearchItemLimit = 100
// remoteSearchBudget 限制远程结果最多给一次请求增加多少延迟。
remoteSearchBudget = 2 * time.Second
// remoteSearchCacheTTL 让同一关键词的翻页与重复搜索复用远程结果,避免每翻
// 一页都重新打一遍远程挂载点。
remoteSearchCacheTTL = 30 * time.Second
// remoteSearchFailureCacheTTL 用于上游超时/失败时的短缓存:部分结果只保留
// 几秒,避免反复吃满 remoteSearchBudget,又不至于长期压住后续重试。
remoteSearchFailureCacheTTL = 5 * time.Second
)
// startRemoteMediaSearch 并发发起远程搜索,返回一个只投递一次结果的通道。
// 不满足远程搜索条件时返回 nil。通道带缓冲,调用方即使提前返回也不会泄漏
// goroutine;请求结束时请求上下文被取消,远程调用会随之退出。
func startRemoteMediaSearch(
ctx context.Context,
svc *service.Container,
query string,
visibility service.MediaVisibility,
filter service.MediaSearchFilter,
) <-chan []model.Media {
if svc == nil || svc.EmbyRemote == nil || query == "" {
return nil
}
if filter.LibraryID != "" && !service.IsEmbyRemoteID(filter.LibraryID) {
return nil
}
results := make(chan []model.Media, 1)
go func() {
results <- remoteMediaSearchItems(ctx, svc, query, visibility, filter)
}()
return results
}
func remoteMediaSearchItems(
ctx context.Context,
svc *service.Container,
query string,
visibility service.MediaVisibility,
filter service.MediaSearchFilter,
) []model.Media {
cacheKey := remoteSearchCacheKey(query, visibility)
if svc.Cache != nil {
if cached, ok := svc.Cache.GetObject(cacheKey); ok {
if rows, ok := cached.([]model.Media); ok {
return filterRemoteSearchItems(rows, filter)
}
}
}
searchCtx, cancel := context.WithTimeout(ctx, remoteSearchBudget)
defer cancel()
remote, _ := svc.EmbyRemote.RemoteSearchMedia(searchCtx, query, remoteSearchItemLimit, visibility)
if svc.Cache != nil {
// 空结果也要缓存:否则查不到片名时每次都会重新等到 remoteSearchBudget。
if remote == nil {
remote = []model.Media{}
}
ttl := remoteSearchCacheTTL
if searchCtx.Err() != nil {
ttl = remoteSearchFailureCacheTTL
}
svc.Cache.SetObject(cacheKey, remote, ttl)
}
return filterRemoteSearchItems(remote, filter)
}
func filterRemoteSearchItems(remote []model.Media, filter service.MediaSearchFilter) []model.Media {
if len(remote) == 0 {
return nil
}
out := make([]model.Media, 0, len(remote))
for _, item := range remote {
if remoteSearchMatchesFilter(item, filter) {
out = append(out, item)
}
}
return out
}
// remoteSearchCacheKey 按可见范围隔离远程搜索结果,避免把某个用户可见挂载
// 点的结果复用给可见范围不同的用户。
func remoteSearchCacheKey(query string, visibility service.MediaVisibility) string {
allowed := append([]string(nil), visibility.AllowedLibraryIDs...)
hidden := append([]string(nil), visibility.HiddenLibraryIDs...)
sort.Strings(allowed)
sort.Strings(hidden)
return fmt.Sprintf("media:obj:remote-search:%t|%s|%s|%s",
visibility.IncludeNSFW, strings.Join(allowed, ","), strings.Join(hidden, ","), query)
}
func writeMediaSearchResults[T any](c *gin.Context, items []T, page, size, limit int, paginated bool) {
if items == nil {
items = []T{}
}
if paginated {
c.JSON(http.StatusOK, gin.H{
"items": paginateSlice(items, page, size),
"total": len(items), "page": page, "page_size": size,
})
return
}
if len(items) > limit {
items = items[:limit]
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func remoteSearchMatchesFilter(item model.Media, filter service.MediaSearchFilter) bool {
if filter.LibraryID != "" && item.LibraryID != filter.LibraryID && item.DisplayLibraryID != filter.LibraryID {
return false
}
if filter.YearMin > 0 && item.Year < filter.YearMin || filter.YearMax > 0 && item.Year > filter.YearMax {
return false
}
if len(filter.Genres) == 0 {
return true
}
normalize := func(value string) string {
return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(value), " ", ""))
}
for _, genre := range strings.Split(strings.ReplaceAll(item.Genres, ",", ","), ",") {
for _, wanted := range filter.Genres {
if normalize(genre) == normalize(wanted) {
return true
}
}
}
return false
}
func normalizeMediaSearchSort(value string) string {
value = strings.TrimSpace(value)
switch value {
case "title_asc", "year_desc", "rating_desc":
return value
default:
return ""
}
}
func sortSearchMedia(items []model.Media, sortBy string) {
if sortBy == "" {
return
}
sort.SliceStable(items, func(i, j int) bool {
return mediaSearchSortLess(items[i], items[j], sortBy)
})
}
func mediaSearchSortLess(a, b model.Media, sortBy string) bool {
switch sortBy {
case "title_asc":
left, right := strings.ToLower(strings.TrimSpace(a.Title)), strings.ToLower(strings.TrimSpace(b.Title))
if left != right {
return left < right
}
case "year_desc":
if a.Year != b.Year {
return a.Year > b.Year
}
case "rating_desc":
if a.Rating != b.Rating {
return a.Rating > b.Rating
}
}
if !a.CreatedAt.Equal(b.CreatedAt) {
return a.CreatedAt.After(b.CreatedAt)
}
return a.ID < b.ID
}
func boundedSearchYear(value string) int {
year, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || year < 1870 || year > 2200 {
return 0
}
return year
}
func streamHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
@@ -0,0 +1,136 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// TestListLibrariesRemotePreviewIsBounded 锁定远程库预览的总预算:
// 上游 Emby 卡住时,/api/libraries?with_preview=1 必须按时返回远程库条目
// (只是没有卡片),而不是跟着上游一起等十几秒。线上实测这个接口因此出现过
// 17.4 秒、30 秒(前端放弃)的响应,设置页也跟着整页空转。
func TestListLibrariesRemotePreviewIsBounded(t *testing.T) {
gin.SetMode(gin.TestMode)
oldBudget := remoteLibraryPreviewBudget
remoteLibraryPreviewBudget = 200 * time.Millisecond
t.Cleanup(func() { remoteLibraryPreviewBudget = oldBudget })
// Views / 用户列表正常返回,Preview(Items 查询)永远不返回:模拟上游半挂。
release := make(chan struct{})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
path := r.URL.Path
switch {
case strings.HasSuffix(path, "/Views"):
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{{
"Id": "view-1", "Name": "远程动画", "CollectionType": "tvshows",
}},
})
return
case strings.HasSuffix(path, "/Users"):
// api_key 账号要先解析出远程用户 Id,否则请求路径会落到兜底 "0"。
_ = json.NewEncoder(w).Encode([]map[string]any{{"Id": "uid-1", "Name": "admin"}})
return
case r.Method == http.MethodPost:
_ = json.NewEncoder(w).Encode(map[string]any{
"AccessToken": "fake-token",
"User": map[string]any{"Id": "uid-1"},
})
return
}
select {
case <-release:
case <-r.Context().Done():
case <-time.After(30 * time.Second):
}
w.WriteHeader(http.StatusGatewayTimeout)
}))
defer upstream.Close()
defer close(release)
// 唯一库名 + cache=shared:预览协程会并发读库(账号解析)。
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.StrmAccount{}, &model.EmbyMount{}, &model.Setting{}, &model.User{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
if err := db.Create(&model.User{Base: model.Base{ID: "admin-1"}, Username: "admin", Role: "admin"}).Error; err != nil {
t.Fatal(err)
}
rawConfig, _ := json.Marshal(map[string]string{"url": upstream.URL, "token": "fake-token"})
acct := model.StrmAccount{
Base: model.Base{ID: "acct-1"}, Name: "远程Emby", Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig), Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), &acct); err != nil {
t.Fatal(err)
}
mount := model.EmbyMount{
Base: model.Base{ID: "mount-1"}, AccountID: acct.ID, RemoteViewID: "view-1",
RemoteViewName: "远程动画", CollectionType: "tvshows", Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), &mount); err != nil {
t.Fatal(err)
}
svc := &service.Container{
Repo: repos,
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
EmbyRemote: service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())),
Cache: service.NewRuntimeCacheService(&config.Config{}, zap.NewNop()),
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.CtxUserID, "admin-1")
c.Set(middleware.CtxUserRole, "admin")
c.Request = httptest.NewRequest(http.MethodGet, "/api/libraries?with_preview=1&include_hidden=1", nil)
start := time.Now()
listLibrariesHandler(svc)(c)
elapsed := time.Since(start)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if elapsed > 2*time.Second {
t.Fatalf("request took %v with a hung upstream; the preview budget must bound it", elapsed)
}
var payload []webLibraryPayload
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v (%s)", err, w.Body.String())
}
var remote *webLibraryPayload
for i := range payload {
if payload[i].IsRemoteEmby {
remote = &payload[i]
break
}
}
if remote == nil {
t.Fatalf("remote library missing from the bounded response: %#v", payload)
}
if len(remote.Cards) != 0 || remote.Total != 0 {
t.Fatalf("expected the preview to be skipped on timeout, got total=%d cards=%d", remote.Total, len(remote.Cards))
}
}
+37
View File
@@ -0,0 +1,37 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
func mediaQualityHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
if page < 1 {
page = 1
} else if page > 1000000 {
page = 1000000
}
if pageSize < 1 {
pageSize = 50
} else if pageSize > 100 {
pageSize = 100
}
issue := c.DefaultQuery("issue", "unmatched")
if issue != "unmatched" && issue != "poster" && issue != "missing" {
c.JSON(http.StatusBadRequest, gin.H{"error": "未知检查分类"})
return
}
items, total, err := svc.Media.QualityIssues(c.Request.Context(), issue, page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": pageSize})
}
}
@@ -0,0 +1,88 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"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 TestSearchMediaSortLimitFiltersAndDeepPagination(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.User{}, &model.Setting{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
lib := model.Library{Name: "Review", Path: "/review", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
rows := make([]model.Media, 2001)
for i := range rows {
rows[i] = model.Media{
Base: model.Base{ID: fmt.Sprintf("review-%04d", i)}, LibraryID: lib.ID,
Title: fmt.Sprintf("Review %04d", i), Path: fmt.Sprintf("/review/%04d.mp4", i), Year: 2020, Genres: "Comedy",
}
}
rows[2000].Year = 2025
rows[2000].Genres = "Drama"
if err := db.CreateInBatches(rows, 100).Error; err != nil {
t.Fatal(err)
}
svc := &service.Container{Repo: repos, Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos)}
cases := []struct {
name, query, expectedID string
count, total, page, size int
}{
{name: "sorted output limit", query: "sort=year_desc&limit=1", expectedID: "review-2000", count: 1},
{name: "page past old 2000 cap", query: "sort=title_asc&page=2001&page_size=1", expectedID: "review-2000", count: 1, total: 2001, page: 2001, size: 1},
{name: "genre filtering", query: "genre=Drama&library_id=" + lib.ID, expectedID: "review-2000", count: 1},
{name: "normalized page", query: "page=0&page_size=0", count: 50, total: 2001, page: 1, size: 50},
{name: "bounded page size", query: "page=1&page_size=99999", count: 2000, total: 2001, page: 1, size: 2000},
{name: "huge page does not overflow", query: "page=" + strconv.Itoa(int(^uint(0)>>1)) + "&page_size=2000", count: 0, total: 2001, page: int(^uint(0) >> 1), size: 2000},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(w)
ctx.Set(middleware.CtxUserID, "admin")
ctx.Set(middleware.CtxUserRole, "admin")
ctx.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=Review&group_versions=0&"+tc.query, nil)
searchMediaHandler(svc)(ctx)
var response struct {
Items []model.Media `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"page_size"`
}
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &response) != nil {
t.Fatalf("search failed: status=%d body=%s", w.Code, w.Body.String())
}
if len(response.Items) != tc.count || response.Total != tc.total || response.Page != tc.page || response.Size != tc.size {
t.Fatalf("response count=%d total=%d page=%d size=%d, want %#v", len(response.Items), response.Total, response.Page, response.Size, tc)
}
if tc.expectedID != "" && response.Items[0].ID != tc.expectedID {
t.Fatalf("first ID=%q want %q", response.Items[0].ID, tc.expectedID)
}
})
}
}
@@ -0,0 +1,153 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// TestSearchMediaRemoteSearchBoundedAndCached 防止远程搜索再次退化:
// 1. 每个挂载点的 Limit 必须是 remoteSearchItemLimit,而不是历史全库查询的 50000;
// 2. 同一关键词的重复搜索应命中缓存,不再打上游;
// 3. 指定本地媒体库时不应枚举远程挂载点。
func TestSearchMediaRemoteSearchBoundedAndCached(t *testing.T) {
gin.SetMode(gin.TestMode)
var mu sync.Mutex
searchCalls := 0
searchLimits := make([]string, 0, 4)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if term := r.URL.Query().Get("SearchTerm"); term != "" {
mu.Lock()
searchCalls++
searchLimits = append(searchLimits, r.URL.Query().Get("Limit"))
mu.Unlock()
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
"Items": []map[string]any{{
"Id": "remote-1",
"Name": "远方剧集",
"Type": "Series",
"ProductionYear": 2024,
}},
})
}))
defer upstream.Close()
// 唯一库名 + cache=shared:远程搜索与本地查询会并发读库,
// 而 :memory: 下每个连接各自一个空库,并发连接会读到「no such table」。
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.StrmAccount{}, &model.EmbyMount{}, &model.Setting{}, &model.User{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
if err := repos.DB.Create(&model.User{Base: model.Base{ID: "user-1"}, Username: "admin", Role: "admin"}).Error; err != nil {
t.Fatal(err)
}
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 := db.Create(&model.Media{
Base: model.Base{ID: "local-1"}, LibraryID: lib.ID, Title: "流浪地球", Path: "/media/movies/流浪地球.mkv",
}).Error; err != nil {
t.Fatal(err)
}
rawConfig, _ := json.Marshal(map[string]string{"url": upstream.URL, "token": "fake-token"})
acct := model.StrmAccount{
Base: model.Base{ID: "acct"}, Name: "远程Emby", Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig), Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), &acct); err != nil {
t.Fatal(err)
}
mount := model.EmbyMount{
Base: model.Base{ID: "mount-1"}, AccountID: acct.ID, RemoteViewID: "view-1",
RemoteViewName: "远程剧集", CollectionType: "tvshows", Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), &mount); err != nil {
t.Fatal(err)
}
svc := &service.Container{
Repo: repos,
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
EmbyRemote: service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())),
Cache: service.NewRuntimeCacheService(&config.Config{}, zap.NewNop()),
}
search := func(params string) []service.MediaItem {
t.Helper()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.CtxUserID, "user-1")
c.Set(middleware.CtxUserRole, "admin")
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?"+params, nil)
searchMediaHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
}
var response struct {
Items []service.MediaItem `json:"items"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
return response.Items
}
callCount := func() int {
mu.Lock()
defer mu.Unlock()
return searchCalls
}
if items := search("q=远方&page=1&page_size=48"); len(items) != 1 || items[0].Title != "远方剧集" {
t.Fatalf("expected remote result, got %#v", items)
}
if calls := callCount(); calls != 1 {
t.Fatalf("expected exactly one upstream search, got %d", calls)
}
mu.Lock()
limit := searchLimits[0]
mu.Unlock()
if limit != "100" {
t.Fatalf("remote search Limit=%q, want bounded 100 (50000 means a full-library query)", limit)
}
// 同一关键词再来一次(例如刷新/重复搜索)应复用缓存,不再请求上游。
if items := search("q=远方&page=1&page_size=48"); len(items) != 1 || items[0].Title != "远方剧集" {
t.Fatalf("cached search lost the remote result: %#v", items)
}
if calls := callCount(); calls != 1 {
t.Fatalf("expected cached remote search to skip upstream, got %d calls", calls)
}
// 指定本地媒体库时不应枚举远程挂载点。
if items := search("q=流浪&page=1&page_size=48&library_id=" + lib.ID); len(items) != 1 || items[0].ID != "local-1" {
t.Fatalf("expected local-only result, got %#v", items)
}
if calls := callCount(); calls != 1 {
t.Fatalf("local library filter must not hit upstream, got %d calls", calls)
}
}
+13 -2
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gin-gonic/gin"
@@ -27,13 +29,17 @@ func TestSearchMediaSeriesKeepsRemoteLibrariesSeparate(t *testing.T) {
"TotalRecordCount": 1,
"Items": []map[string]any{{
"Id": "same-series", "Name": "龙珠改", "Type": "Series",
"ProviderIds": map[string]string{"Tmdb": "61709"},
"ProviderIds": map[string]string{"Tmdb": "61709"},
"ProductionYear": 2024, "Genres": []string{"Science Fiction"},
}},
})
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
// 唯一库名 + cache=shared:远程搜索与本地查询会并发读库,
// 而 :memory: 下每个连接各自一个空库,并发连接会读到「no such table」。
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
@@ -108,4 +114,9 @@ func TestSearchMediaSeriesKeepsRemoteLibrariesSeparate(t *testing.T) {
}
seen[item.DisplayLibraryID] = true
}
remoteLibraryID := service.EncodeEmbyRemoteID("mount-a", "view-a")
items, total := request("page=1&page_size=8&library_id=" + url.QueryEscape(remoteLibraryID) + "&year_min=2020&genre=Science+Fiction")
if total != 1 || len(items) != 1 || items[0].DisplayLibraryID != remoteLibraryID {
t.Fatalf("remote library/year/genre filters lost results: total=%d items=%#v", total, items)
}
}
+4 -1
View File
@@ -559,7 +559,10 @@ func TestSearchMediaHandlerIncludesEmbyRemote(t *testing.T) {
}))
defer server.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
// 唯一库名 + cache=shared:远程搜索与本地查询会并发读库,
// 而 :memory: 下每个连接各自一个空库,并发连接会读到「no such table」。
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
+72 -1
View File
@@ -1,13 +1,18 @@
// Package handler — 片头/片尾片段接口。
//
// GET /playback/:id/segments
// GET /playback/:id/segments
// PUT /playback/:id/segments {kind, position_ms, clear}
//
// 单独开一个接口而不是塞进 /media/:id/playback,有两个原因:一是抓取外部数据
// 可能要几秒,不能拖慢决定能否起播的那个请求;二是片段与播放来源(本地 / 云盘 /
// 远程 Emby)无关,独立出来对所有媒体一致。
//
// 写入接口只处理「手动标记」:用户播放时把当前位置标成片头结束 / 片尾开始。
// 外部数据永远由提供方刷新,不接受客户端写入。
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
@@ -42,6 +47,72 @@ func playbackSegmentsHandler(svc *service.Container) gin.HandlerFunc {
}
}
// playbackSegmentsMarkRequest 是手动标记的入参。
type playbackSegmentsMarkRequest struct {
// Kind 只接受 intro / credits。
Kind string `json:"kind" binding:"required"`
// PositionMs 是当前播放位置:标片头时表示片头结束点,标片尾时表示片尾开始点。
PositionMs int64 `json:"position_ms"`
// Clear 为 true 时删除该类型的标记。
Clear bool `json:"clear"`
}
// playbackSegmentsMarkHandler 写入或清除一条手动标记,返回更新后的完整片段列表。
//
// 返回整份列表而不是只回 204:播放器需要立刻反映合并后的结果(手动标记优先级
// 最高),避免自己再发一次 GET 造成竞态。
func playbackSegmentsMarkHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc.Segments == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "segments unavailable"})
return
}
m, err := findMediaForPlaybackEndpoint(c, svc, c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
// 远程 Emby 挂载是上游库的投影,本地标记没有可落地的地方。
if service.IsEmbyRemoteID(m.ID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "remote media cannot be marked"})
return
}
var req playbackSegmentsMarkRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
err = svc.Segments.SetManualMark(c.Request.Context(), m, service.ManualMarkRequest{
Kind: req.Kind,
PositionMs: req.PositionMs,
Clear: req.Clear,
})
if errors.Is(err, service.ErrInvalidManualMark) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid skip mark"})
return
}
if err != nil {
if svc.Log != nil {
svc.Log.Warn("save manual skip mark failed",
zap.String("media_id", m.ID), zap.Error(err))
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"})
return
}
rows, listErr := svc.Segments.ListForPlayback(c.Request.Context(), m)
if listErr != nil {
if svc.Log != nil {
svc.Log.Debug("list media segments after mark failed",
zap.String("media_id", m.ID), zap.Error(listErr))
}
}
c.JSON(http.StatusOK, gin.H{
"segments": service.ToSegmentViews(rows),
"auto_skip": resolveAutoSkipFlag(c, svc),
})
}
}
// resolveAutoSkipFlag reads the「自动跳过片头」switch off whichever profile is
// currently in effect. It reuses selectedPlayProfile so the server agrees with
// the UI about which profile is active (explicit header first, then the user's
+764
View File
@@ -0,0 +1,764 @@
package handler
import (
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader"
)
// 媒体代理在「上游签名票被拒」时的行为契约。
//
// 背景(真实故障):聆阅·懒人听书源的音频直链是第三方中转现签的腾讯 CDN 票,
// 票被 CDN 判掉时上游回的是**空的 401**(无 Content-Type、无 body)。旧行为把它
// 原样透传给播放器,播放器重试的还是同一个 URL,于是 App 日志里连续 4~8 个
// ERROR_CODE_IO_BAD_HTTP_STATUS (2004),表现为「这本书怎么都播不了」。
//
// 现在的契约:
// - 能换票 → 用新票重拉,直接返回媒体本体(第一个请求就被治好);
// - 换不到 → 502 + 明确文案,而不是把 401 丢给播放器;
// - 非音轨地址(漫画图片防盗链等)→ 维持原样透传,不改变既有行为。
func TestReaderMediaPayloadRejected(t *testing.T) {
cases := []struct {
name string
head string
contentType string
want bool
}{
{"HTML 错误页", "<!DOCTYPE html><html><body>403</body></html>", "text/html; charset=utf-8", true},
{"JSON 报错", `{"code":403,"message":"expired"}`, "application/json", true},
{"数组形态报错", `[{"error":"bad ticket"}]`, "application/json; charset=utf-8", true},
{"纯文本报错", "ticket expired", "text/plain", false},
{"正常 m4a", "\x00\x00\x00\x20ftypmp42", "audio/mp4", false},
{"正常 mp3", "ID3\x04\x00", "audio/mpeg", false},
{"正常图片", "\xff\xd8\xff\xe0", "image/jpeg", false},
{"播放列表", "#EXTM3U\n#EXTINF:10,", "application/vnd.apple.mpegurl", false},
{"空体", "", "text/html", false},
{"HTML 类型但二进制体", "\x00\x00\x00\x20ftypmp42", "text/html", false},
}
for _, c := range cases {
got := readerMediaPayloadRejected([]byte(c.head), c.contentType)
if got != c.want {
t.Errorf("%s: readerMediaPayloadRejected(%q, %q) = %v,期望 %v",
c.name, c.head, c.contentType, got, c.want)
}
}
}
// TestReaderMediaProxyHealsStaleTicket 票被上游拒绝时代理自己换票重拉,
// 第一个请求就返回媒体本体(不必等播放器重试)。
func TestReaderMediaProxyHealsStaleTicket(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
signed := primeTicketBook(t, svc, book.ID)
// 让中转再签一张新票:章节缓存里那张就此变成「旧票」。
srv.rotate()
code, body := getReaderMedia(t, router, signed)
if code != http.StatusOK {
t.Fatalf("换票重试后 status=%d body=%s", code, body)
}
if !strings.Contains(body, "fake-audio-bytes") {
t.Fatalf("响应体不是媒体: %q", body)
}
if got := srv.contentHits(); got < 2 {
t.Fatalf("应当回源重取章节,实际请求 %d 次", got)
}
}
// TestReaderMediaProxyFailsClearlyWhenRefreshImpossible 换不到票时给 502 + 明确文案,
// 而不是把 401 透传给播放器(那样播放器只会对着必然失败的地址反复重试)。
func TestReaderMediaProxyFailsClearlyWhenRefreshImpossible(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
signed := primeTicketBook(t, svc, book.ID)
srv.rotate()
srv.stopIssuing()
code, body := getReaderMedia(t, router, signed)
if code != http.StatusBadGateway {
t.Fatalf("换票失败应当返回 502,实际 %d body=%s", code, body)
}
if !strings.Contains(body, "签名失效") {
t.Fatalf("错误文案应说明是音源签名失效: %s", body)
}
}
// TestReaderMediaProxyStopsResendingRejectedTicket 上游成批拒绝时(连新签的票
// 也是空 401),不能在播放器那一次请求里把同一张被拒的票打上十几遍:连续两轮
// 换票都是同一个状态码就立刻失败,把明确文案交出去。
func TestReaderMediaProxyStopsResendingRejectedTicket(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
signed := primeTicketBook(t, svc, book.ID)
srv.denyAlways()
code, body := getReaderMedia(t, router, signed)
if code != http.StatusBadGateway {
t.Fatalf("上游持续拒绝应返回 502,实际 %d body=%s", code, body)
}
if !strings.Contains(body, "401") {
t.Fatalf("错误文案应带上上游状态码: %s", body)
}
// 每轮 fetchReaderMedia = 首拉(401) + 换票后重拉(401),两轮即收手。
if got := srv.mediaRequests(); got > 4 {
t.Fatalf("无进展的拒绝应当两轮就收手,实际上游请求 %d 次", got)
}
}
// TestReaderAudioCacheResumesTruncatedUpstream 上游中途 RST 后用 Range 续传:
// 播放器最终拿到的仍是完整的一章,而不是从头重下。
func TestReaderAudioCacheResumesTruncatedUpstream(t *testing.T) {
const bodyBytes = 300 << 10
payload := bytes.Repeat([]byte("m4a!"), bodyBytes/4)
var ranges []string
var mu sync.Mutex
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
ranges = append(ranges, r.Header.Get("Range"))
count := len(ranges)
mu.Unlock()
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Accept-Ranges", "bytes")
start := 0
if count > 1 {
// 第二次起按 Range 续传。
start = bodyBytes / 2
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, bodyBytes-1, bodyBytes))
w.WriteHeader(http.StatusPartialContent)
} else {
w.Header().Set("Content-Length", strconv.Itoa(bodyBytes))
}
chunk := payload[start:]
if count == 1 {
// 第一次只写一半就断开。
chunk = payload[:bodyBytes/2]
}
for written := 0; written < len(chunk); written += 8 << 10 {
end := written + 8<<10
if end > len(chunk) {
end = len(chunk)
}
if _, err := w.Write(chunk[written:end]); err != nil {
return
}
}
w.(http.Flusher).Flush()
if count == 1 {
if hj, ok := w.(http.Hijacker); ok {
if conn, _, err := hj.Hijack(); err == nil {
_ = conn.Close()
}
}
}
}))
defer srv.Close()
svc, _, book := prepareMediaTicketBookWithUpstream(t, srv.URL)
router := mediaRouterForTest(svc)
signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media")
code, body := getReaderMedia(t, router, signed)
if code != http.StatusOK {
t.Fatalf("续传后应当成功,实际 status=%d body=%.120s", code, body)
}
if len(body) != bodyBytes {
t.Fatalf("交付字节 = %d,期望 %d", len(body), bodyBytes)
}
mu.Lock()
defer mu.Unlock()
if len(ranges) < 2 || ranges[0] != "" {
t.Fatalf("请求序列 = %q,期望先整段再续传", ranges)
}
if !strings.HasPrefix(ranges[1], "bytes=") {
t.Fatalf("第二轮应当带 Range,实际 %q", ranges[1])
}
}
// TestReaderMediaProxyPassesThroughNonTrackError 漫画图片这类不可换票的地址,
// 上游 4xx 仍按原状态透传(既有行为不变)。
func TestReaderMediaProxyPassesThroughNonTrackError(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
// 书源域名下的图片地址:不在任何音轨缓存里。
imgURL := srv.URL + "/img/forbidden.jpg"
signed := svc.Reader.ProxyURL(book.ID, imgURL)
code, body := getReaderMedia(t, router, signed)
if code == http.StatusBadGateway || code == http.StatusUnauthorized {
t.Fatalf("非音轨地址不该走换票分支,实际 %d body=%s", code, body)
}
if code != http.StatusForbidden {
t.Fatalf("图片 403 应原样透传,实际 %d body=%s", code, body)
}
if got := srv.contentHits(); got != 0 {
t.Fatalf("图片失败不该触发回源取章节,实际 %d 次", got)
}
}
// TestReaderMediaProxyRejectsNonMediaPayload 上游用 200 回一段 HTML/JSON 错误页时,
// 代理直接判失败,不把网页喂给播放器(否则报「格式不支持」)。
func TestReaderMediaProxyRejectsNonMediaPayload(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
signed := primeTicketBook(t, svc, book.ID)
srv.serveErrorPage()
code, body := getReaderMedia(t, router, signed)
if code != http.StatusBadGateway {
t.Fatalf("非媒体内容应当返回 502,实际 %d body=%.160s", code, body)
}
if !strings.Contains(body, "非媒体内容") {
t.Fatalf("错误文案应说明上游返回了非媒体内容: %s", body)
}
}
// ─── 上游抖动:连接被 reset 时的重试与截断可见化 ─────────────────────────
// TestReaderMediaProbeRetriesConnectionReset 上游在响应体中途 RST 连接时,
// 代理要退避重试,而不是第一次读失败就回 502。
//
// 真实故障:腾讯 CDN 的跨境边缘在并发/长传输时会直接 RST,同一章重试一次
// 往往就好。以前这里读 body 失败即判死,把一次可恢复的抖动变成「这本书播不了」。
func TestReaderMediaProbeRetriesConnectionReset(t *testing.T) {
var attempts int32
const bodyBytes = 200 << 10
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&attempts, 1)
if n == 1 {
// 第一次:声明长度,写一小段后直接断开连接(模拟 RST/提前 EOF)。
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Content-Length", strconv.Itoa(bodyBytes))
_, _ = w.Write([]byte("\x00\x00\x00\x20ftypmp42"))
w.(http.Flusher).Flush()
if hj, ok := w.(http.Hijacker); ok {
if conn, _, err := hj.Hijack(); err == nil {
_ = conn.Close()
}
}
return
}
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Content-Length", strconv.Itoa(bodyBytes))
payload := make([]byte, 8<<10)
for written := 0; written < bodyBytes; written += len(payload) {
if _, err := w.Write(payload); err != nil {
return
}
}
}))
defer srv.Close()
svc, _, book := prepareMediaTicketBookWithUpstream(t, srv.URL)
router := mediaRouterForTest(svc)
signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media")
code, body := getReaderMedia(t, router, signed)
if code != http.StatusOK {
t.Fatalf("连接被 reset 后应当重试成功,实际 status=%d body=%.200s", code, body)
}
if int32(len(body)) != bodyBytes {
t.Fatalf("交付字节 = %d,期望 %d", len(body), bodyBytes)
}
if got := atomic.LoadInt32(&attempts); got < 2 {
t.Fatalf("应当重试过,实际上游请求 %d 次", got)
}
}
// TestReaderMediaAbortsWhenUpstreamTruncates 非音轨的流式透传路径:上游中途
// 断开且重试都失败时,必须让客户端看到「传输不完整」,不能返回一个长度正确
// 却内容缺失的响应。
//
// 以前这里是 `_, _ = io.Copy(...)`,错误被丢掉,客户端拿到一个正常结束的
// 半截文件 —— 播放器只能报「格式不支持」(3003),根因被彻底掩盖。
//
// 音轨走的是整段落盘缓存(见 TestReaderAudioCacheRejectsTruncatedUpstream):
// 拉不完整就重试,全部失败回 502,绝不会把半截音频交给播放器。
func TestReaderMediaAbortsWhenUpstreamTruncates(t *testing.T) {
const declared = 400 << 10
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Content-Length", strconv.Itoa(declared))
// 先写够探测用的前缀,再断开:探测能过,透传阶段才断。
payload := make([]byte, 8<<10)
for written := 0; written < 128<<10; written += len(payload) {
if _, err := w.Write(payload); err != nil {
return
}
}
w.(http.Flusher).Flush()
if hj, ok := w.(http.Hijacker); ok {
if conn, _, err := hj.Hijack(); err == nil {
_ = conn.Close()
}
}
}))
defer srv.Close()
svc, _, book := prepareMediaTicketBookWithUpstream(t, srv.URL)
router := mediaRouterForTest(svc)
// .mp4 不是音频后缀:走流式透传分支(音轨会被整段落盘缓存)。
signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media.mp4")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, signed, nil)
// panic(http.ErrAbortHandler) 由 net/http 捕获;httptest 下会向上冒泡,
// 这里断言它确实发生了(即「没有静默地把半截文件当成功」)。
defer func() {
recovered := recover()
if recovered == nil {
t.Fatalf("上游截断时必须中止响应(panic(http.ErrAbortHandler)),实际正常返回了 %d 字节",
w.Body.Len())
}
if recovered != http.ErrAbortHandler {
t.Fatalf("中止响应时应使用 http.ErrAbortHandler,实际 %v", recovered)
}
}()
router.ServeHTTP(w, req)
if w.Body.Len() >= declared {
t.Fatalf("不应交付完整长度的响应体(上游只写了 128KB)")
}
}
// TestReaderAudioCacheServesFromDisk 音轨首次拉取后落盘;后续请求即使带着一张
// 新签的票(query 全变了)也不再回源——这是让播放不受跨境链路抖动影响的关键。
func TestReaderAudioCacheServesFromDisk(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
signed := primeTicketBook(t, svc, book.ID)
code, body := getReaderMedia(t, router, signed)
if code != http.StatusOK || !strings.Contains(body, "fake-audio-bytes") {
t.Fatalf("首次拉取失败 status=%d body=%q", code, body)
}
if got := srv.mediaRequests(); got != 1 {
t.Fatalf("首次拉取应对上游请求 1 次,实际 %d", got)
}
// 同一段音频的另一张票:URL 的 query 不同,缓存键必须一致。
rotated := svc.Reader.ProxyURL(book.ID, srv.URL+"/media?t=ticket-rotated")
code, body = getReaderMedia(t, router, rotated)
if code != http.StatusOK || !strings.Contains(body, "fake-audio-bytes") {
t.Fatalf("命中缓存失败 status=%d body=%q", code, body)
}
if got := srv.mediaRequests(); got != 1 {
t.Fatalf("命中缓存后不该再回源,实际上游请求 %d 次", got)
}
}
// TestReaderAudioCacheAbortsOnTruncatedUpstream 首播直通模式下上游持续截断的契约:
//
// 冷启动走边下边发(见 handleStreamingReaderAudio),响应头和前缀字节在探测
// 通过后就已发出,因此中途截断不可能再改写成 502 —— 必须中止连接让客户端
// 判定「传输不完整」,同时保留 .part 分片。
//
// 关键不变量:**绝不能留下可命中的完整缓存**(否则后续会把截断音频当真完整
// 文件下发,播放器表现为「时长正确但没有声音」)。下一次请求带着分片走续传
// 路径,那时才可能给出 502 + 明确文案。
func TestReaderAudioCacheAbortsOnTruncatedUpstream(t *testing.T) {
const declared = 400 << 10
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Content-Length", strconv.Itoa(declared))
payload := make([]byte, 8<<10)
for written := 0; written < 128<<10; written += len(payload) {
if _, err := w.Write(payload); err != nil {
return
}
}
w.(http.Flusher).Flush()
if hj, ok := w.(http.Hijacker); ok {
if conn, _, err := hj.Hijack(); err == nil {
_ = conn.Close()
}
}
}))
defer srv.Close()
svc, _, book := prepareMediaTicketBookWithUpstream(t, srv.URL)
router := mediaRouterForTest(svc)
// 无后缀地址 + 音频书籍 → 走音轨落盘缓存。
signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, signed, nil)
// panic(http.ErrAbortHandler) 由 net/http 捕获;httptest 下会向上冒泡,
// 这里断言它确实发生了(即「没有静默地把半截音频当成功」)。
func() {
defer func() {
recovered := recover()
if recovered == nil {
t.Fatalf("上游截断时必须中止响应,实际正常返回了 %d 字节", w.Body.Len())
}
if recovered != http.ErrAbortHandler {
t.Fatalf("中止响应时应使用 http.ErrAbortHandler,实际 %v", recovered)
}
}()
router.ServeHTTP(w, req)
}()
if w.Body.Len() >= declared {
t.Fatalf("不应交付完整长度的响应体(上游只写了 128KB)")
}
if _, _, ok := svc.Reader.OpenCachedRemoteAudio(book.ID, srv.URL+"/media"); ok {
t.Fatalf("中断的下载不能留下可命中的完整缓存")
}
if !svc.Reader.HasPartialRemoteAudio(book.ID, srv.URL+"/media") {
t.Fatalf("中断后应保留 .part 分片供续传")
}
// 二次请求带分片走续传路径;上游依旧忽略 Range 并截断 → 这时才给 502。
code, body := getReaderMedia(t, router, signed)
if code != http.StatusBadGateway {
t.Fatalf("续传路径持续失败应返回 502,实际 %d body=%.120s", code, body)
}
if strings.Contains(body, "ftyp") {
t.Fatalf("不能把半截音频交给客户端: %q", body)
}
if _, _, ok := svc.Reader.OpenCachedRemoteAudio(book.ID, srv.URL+"/media"); ok {
t.Fatalf("失败的续传仍不应留下完整缓存")
}
}
func mediaRouterForTest(svc *service.Container) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
// /api/reader/media 挂在公共 api 组(签名鉴权,不需要 JWT),单独注册即可。
r.GET("/api/reader/media", readerMediaProxyHandler(svc))
return r
}
func getReaderMedia(t *testing.T, router *gin.Engine, signedPath string) (int, string) {
t.Helper()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, signedPath, nil)
router.ServeHTTP(w, req)
return w.Code, w.Body.String()
}
// primeTicketBook 先读一次章节(签出第一张票并落缓存),返回该音轨的签名代理路径。
func primeTicketBook(t *testing.T, svc *service.Container, bookID string) string {
t.Helper()
out, err := svc.Reader.GetContentForBook(t.Context(), "u1", bookID, 0, reader.ContentOptions{})
if err != nil {
t.Fatalf("首次取章节失败: %v", err)
}
if len(out.Tracks) != 1 {
t.Fatalf("音轨数 = %d,期望 1", len(out.Tracks))
}
return out.Tracks[0]
}
// ─── 模拟「中转现签 CDN 直链」的上游 ───────────────────────────────────────
// mediaTicketServer:
// - /book/1 详情页
// - /content 章节正文,每次请求签一张新票
// - /media 媒体本体,只认最新签出的票;旧票回**空的 401**(与腾讯 CDN 一致)
// - /img/* 图片资源,固定 403(模拟防盗链)
type mediaTicketServer struct {
*httptest.Server
mu sync.Mutex
issued []string
hits int
mediaHits int
noToken bool
errorPage bool
alwaysDeny bool
}
func newMediaTicketServer(t *testing.T) *mediaTicketServer {
t.Helper()
s := &mediaTicketServer{}
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/book/1"):
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<html><body><h1>票源之书</h1></body></html>`))
case r.URL.Path == "/content":
s.mu.Lock()
s.hits++
n := s.hits
noToken := s.noToken
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if noToken {
_, _ = w.Write([]byte(`{"data":{"url":""}}`))
return
}
token := s.newTokenLocked(fmt.Sprintf("ticket-%d", n))
_, _ = w.Write([]byte(fmt.Sprintf(`{"data":{"url":"%s/media?t=%s"}}`, s.URL, token)))
case r.URL.Path == "/media":
s.mu.Lock()
s.mediaHits++
deny := s.alwaysDeny
s.mu.Unlock()
if deny {
// 与腾讯 CDN 一致:连新签的票也直接空 401(成批拒绝的那几分钟)。
w.WriteHeader(http.StatusUnauthorized)
return
}
if s.errorPageNow() {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<html><body>ticket expired</body></html>`))
return
}
token := r.URL.Query().Get("t")
s.mu.Lock()
valid := len(s.issued) > 0 && s.issued[len(s.issued)-1] == token
s.mu.Unlock()
if !valid {
// 与上游一致:空 body、无 Content-Type。
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "audio/mp4")
_, _ = w.Write([]byte("\x00\x00\x00\x20ftypmp42fake-audio-bytes"))
case strings.HasPrefix(r.URL.Path, "/img/"):
w.WriteHeader(http.StatusForbidden)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(s.Close)
return s
}
// newTokenLocked 签一张新票(调用方需持有或随后获取锁;这里内部加锁)。
func (s *mediaTicketServer) newTokenLocked(token string) string {
s.mu.Lock()
defer s.mu.Unlock()
s.issued = append(s.issued, token)
return token
}
// rotate 模拟「中转又签了一张新票」:章节缓存里那张就此失效。
func (s *mediaTicketServer) rotate() {
s.mu.Lock()
s.issued = append(s.issued, fmt.Sprintf("rotated-%d", len(s.issued)))
s.mu.Unlock()
}
// stopIssuing 让书源此后给不出播放地址(模拟中转本身挂了)。
func (s *mediaTicketServer) stopIssuing() {
s.mu.Lock()
s.noToken = true
s.mu.Unlock()
}
// serveErrorPage 让媒体端点改用 200 + HTML 错误页(直链失效的另一种形态)。
func (s *mediaTicketServer) serveErrorPage() {
s.mu.Lock()
s.errorPage = true
s.mu.Unlock()
}
// denyAlways 让媒体端点连新签的票也一律空 401(模拟上游成批拒绝的那几分钟)。
func (s *mediaTicketServer) denyAlways() {
s.mu.Lock()
s.alwaysDeny = true
s.mu.Unlock()
}
func (s *mediaTicketServer) errorPageNow() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.errorPage
}
func (s *mediaTicketServer) contentHits() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.hits
}
func (s *mediaTicketServer) mediaRequests() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.mediaHits
}
// prepareMediaTicketBookWithUpstream 用指定的上游地址建一本书(上游由调用方提供)。
func prepareMediaTicketBookWithUpstream(t *testing.T, upstream string) (*service.Container, *mediaTicketServer, *model.ReaderBook) {
t.Helper()
svc := newReaderHandlerContainer(t)
svc.Cfg.Cache.CacheDir = t.TempDir()
// 占位 ticket server:只用来满足清理钩子,不参与请求。
srv := newMediaTicketServer(t)
srcJSON := fmt.Sprintf(`{
"bookSourceName": "上游直连测试",
"bookSourceType": 1,
"bookSourceUrl": %q,
"ruleContent": {"content": "$.data.url"}
}`, upstream)
if _, err := svc.Reader.ImportSources(t.Context(), "u1", srcJSON); err != nil {
t.Fatalf("导入书源失败: %v", err)
}
srcs, err := svc.Reader.ListSources(t.Context(), "u1")
if err != nil || len(srcs) == 0 {
t.Fatalf("读不到书源: %v", err)
}
book, err := svc.Reader.AddBook(t.Context(), "u1", reader.SearchOrigin{
SourceID: srcs[0].ID,
Origin: upstream,
OriginName: "上游直连测试",
OriginType: 1,
BookURL: upstream + "/book/1",
}, "上游直连之书", "某作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
return svc, srv, book
}
func prepareMediaTicketBook(t *testing.T) (*service.Container, *mediaTicketServer, *model.ReaderBook) {
t.Helper()
svc := newReaderHandlerContainer(t)
// 章节缓存必须落在磁盘上:换票要凭落盘载荷找回「这个地址属于哪一章」。
// newReaderHandlerContainer 只给空 cfg,这里补一个临时缓存目录。
svc.Cfg.Cache.CacheDir = t.TempDir()
srv := newMediaTicketServer(t)
srcJSON := fmt.Sprintf(`{
"bookSourceName": "票源测试",
"bookSourceType": 1,
"bookSourceUrl": %q,
"ruleContent": {"content": "$.data.url"}
}`, srv.URL)
if _, err := svc.Reader.ImportSources(t.Context(), "u1", srcJSON); err != nil {
t.Fatalf("导入书源失败: %v", err)
}
srcs, err := svc.Reader.ListSources(t.Context(), "u1")
if err != nil || len(srcs) == 0 {
t.Fatalf("读不到书源: %v", err)
}
book, err := svc.Reader.AddBook(t.Context(), "u1", reader.SearchOrigin{
SourceID: srcs[0].ID,
Origin: srv.URL,
OriginName: "票源测试",
OriginType: 1,
BookURL: srv.URL + "/book/1",
}, "票源之书", "某作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
if err := svc.Reader.SaveChapters(t.Context(), book.ID, []reader.ChapterInput{
{Index: 0, Title: "第一章", URL: srv.URL + "/content"},
}); err != nil {
t.Fatalf("保存目录失败: %v", err)
}
return svc, srv, book
}
// TestReaderAudioStreamsFirstByteBeforeDownloadCompletes 首播 TTFB 回归:
// 冷启动必须边下边发,而不是等整章下完再一次性下发。
//
// 上游在第一块之后卡住不放:直通实现此时应已把那块转给客户端,所以读端能
// 拿到字节;旧实现(整段下载后才 ServeContent)在 release 关闭前一个字节都
// 不会发出,读端会一直阻塞。
func TestReaderAudioStreamsFirstByteBeforeDownloadCompletes(t *testing.T) {
// 总长必须大于探测窗口(readerMediaProbeBytes=64KB):首播要先读完这一小段
// 才能判断上游给的是错误页还是媒体,这之后才可能外发。卡点在探测结束之后,
// 才能证明「整段下完才发」和「边下边发」的区别。
const chunks = 16
const chunkBytes = 16 << 10
const stallAfter = 4 // 写满探测窗口(4×16KB=64KB)后卡住
release := make(chan struct{})
var releaseOnce sync.Once
unblock := func() { releaseOnce.Do(func() { close(release) }) }
defer unblock()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Content-Length", strconv.Itoa(chunks*chunkBytes))
w.WriteHeader(http.StatusOK)
for i := 0; i < chunks; i++ {
if _, err := w.Write(bytes.Repeat([]byte("m4a!"), chunkBytes/4)); err != nil {
return
}
w.(http.Flusher).Flush()
if i+1 == stallAfter {
// 探测窗口刚写满就卡住,直到测试确认客户端已经收到字节。
<-release
}
}
}))
defer upstream.Close()
svc, _, book := prepareMediaTicketBookWithUpstream(t, upstream.URL)
signed := svc.Reader.ProxyURL(book.ID, upstream.URL+"/media")
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/reader/media", readerMediaProxyHandler(svc))
proxy := httptest.NewServer(router)
defer proxy.Close()
resp, err := http.Get(proxy.URL + signed)
if err != nil {
t.Fatalf("请求媒体代理失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("直通应返回 200,实际 %d", resp.StatusCode)
}
// 上游仍卡在 release 上:此时能读到第一批字节,才说明是边下边发。
first := make([]byte, 4)
readDone := make(chan error, 1)
go func() {
_, readErr := io.ReadFull(resp.Body, first)
readDone <- readErr
}()
select {
case readErr := <-readDone:
if readErr != nil {
t.Fatalf("读取首批字节失败: %v", readErr)
}
case <-time.After(5 * time.Second):
unblock()
t.Fatalf("首播没有边下边发:上游卡在中途(仍有 %d 字节未发)时客户端读不到任何字节",
(chunks-stallAfter)*chunkBytes)
}
if string(first) != "m4a!" {
t.Fatalf("首批字节内容不对: %q", first)
}
// 放行上游,把剩余字节读完并确认缓存落盘完整。
unblock()
rest, readErr := io.ReadAll(resp.Body)
if readErr != nil {
t.Fatalf("读取剩余字节失败: %v", readErr)
}
if total := 4 + len(rest); total != chunks*chunkBytes {
t.Fatalf("交付字节 = %d,期望 %d", total, chunks*chunkBytes)
}
// 客户端读完最后一个字节时,服务端可能还在做 truncate + rename:
// 落盘完成是 handler 的收尾动作,给一个有限窗口等它。
deadline := time.Now().Add(2 * time.Second)
for !svc.Reader.HasCachedRemoteAudio(book.ID, upstream.URL+"/media") {
if time.Now().After(deadline) {
t.Fatalf("直通完成后应命中缓存")
}
time.Sleep(10 * time.Millisecond)
}
}
+713 -26
View File
@@ -2,13 +2,23 @@
package handler
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"golang.org/x/sync/singleflight"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
@@ -122,16 +132,27 @@ func readerImportSourcesHandler(svc *service.Container) gin.HandlerFunc {
func readerUpdateSourceHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Enabled *bool `json:"enabled"`
// MediaDirect 媒体直连开关:允许客户端自行下载音频/漫画图片字节。
// 可单独提交,也可与其他字段一起提交(只更新出现的字段)。
MediaDirect *bool `json:"media_direct"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil || body.Enabled == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled 字段必填"})
if err := c.ShouldBindJSON(&body); err != nil || (body.Enabled == nil && body.MediaDirect == nil) {
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled 或 media_direct 字段必填"})
return
}
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), userID, c.Param("id"), *body.Enabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
if body.Enabled != nil {
if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), userID, c.Param("id"), *body.Enabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if body.MediaDirect != nil {
if err := svc.Reader.SetSourceMediaDirect(c.Request.Context(), userID, c.Param("id"), *body.MediaDirect); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -149,10 +170,10 @@ func readerDeleteSourceHandler(svc *service.Container) gin.HandlerFunc {
}
func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Key string `json:"key" binding:"required"`
}
return func(c *gin.Context) {
var body struct {
Key string `json:"key" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -403,6 +424,10 @@ func readerBookInfoHandler(svc *service.Container) gin.HandlerFunc {
}
// 声明了封面解密的书源:详情封面也要走解密代理(尚未入库,按书源 URL 签)。
if info != nil {
// 顺手修复书架里被上游限流写坏的封面(存成接口地址的那种),先按原始
// 地址写回,再做下发时的解密代理改写。
userID := c.GetString(middleware.CtxUserID)
svc.Reader.RepairShelfCover(c.Request.Context(), userID, c.Query("source_url"), c.Query("book_url"), info.CoverURL)
info.CoverURL = svc.Reader.RewriteBookCover(c.Request.Context(), "", c.Query("source_url"), info.CoverURL)
}
c.JSON(http.StatusOK, info)
@@ -869,12 +894,34 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
// 图片地址可自带 ",{headers:{...}}" 选项段:拆分后 headers 逐图应用,
// 代理地址里只保留不带选项的地址(见 ProxyURL)。
rawURL, mediaOptions, _ := readerrule.ParseMediaOptions(rawURL)
resp, err := svc.Reader.FetchMediaWithOptions(c.Request.Context(), book, rawURL, c.GetHeader("Range"), mediaOptions.Headers)
// 远端音轨:整段落盘缓存,命中后不再回源。
//
// 听书源的音频直链签在腾讯音乐 CDN 上,跨境链路会间歇性返回空 401
// 或直接 RST 连接。旧实现每次都把抖动原样交给播放器:要么是 502 的
// JSON,要么是截断的 206,浏览器一律判成 MEDIA_ERR_SRC_NOT_SUPPORTED
// (前端据此提示「格式不支持,请检查 ffmpeg」——其实完全无关)。
// 缓存后只要首播成功一次,后续拖动进度、续播、重试都走本地文件。
// 缓存目录不可用时退回旧的流式透传,不让配置问题直接变成「播不了」。
if c.Request.Method == http.MethodGet &&
reader.IsAudioMediaURL(rawURL, book.Type) &&
svc.Reader.RemoteAudioCachePath(bookID, rawURL) != "" {
handleCachedReaderAudio(c, svc, book, bookID, rawURL, mediaOptions.Headers)
return
}
// 只有 200 的 GET 需要嗅探响应体(判播放列表 / 判错误页);
// 206 的体是二进制分片、HEAD 没有体,读了反而要额外拼回去。
probeBody := c.Request.Method == http.MethodGet
media, err := fetchReaderMedia(c.Request.Context(), svc, book, rawURL, c.GetHeader("Range"), mediaOptions.Headers, probeBody)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "媒体拉取失败: " + err.Error()})
return
}
resp := media.resp
defer resp.Body.Close()
// 换过票时用新地址:m3u8 里的相对分片要按新地址解析。
rawURL = media.url
ct := resp.Header.Get("Content-Type")
// 书源声明了 imageDecode 时,图片字节需要服务端二次解密。
@@ -885,6 +932,7 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadGateway, gin.H{"error": "图片过大或读取失败"})
return
}
sniffed = append(append([]byte(nil), media.head...), sniffed...)
decoded, decErr := svc.Reader.DecodeImageBytes(c.Request.Context(), book, rawURL, false, sniffed)
if decErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "图片解密失败: " + decErr.Error()})
@@ -897,20 +945,21 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
c.Data(resp.StatusCode, contentType, decoded)
return
}
// m3u8 判定不能只看 Content-Type 或 URL 后缀:上游经常把播放列表标成
// application/octet-stream,或者地址是 /index.m3u8?token=...(后缀判断不命中)。
// 先读一小段用 #EXTM3U 标记确认;不是播放列表就把这段拼回响应体继续流式透传。
// 200 的 GET:media.head 已经读过一小段,先用它判播放列表与错误页。
//
// 只嗅探 200 的 GET:206 的体是二进制分片、HEAD 没有体,都不需要判断。
if c.Request.Method == http.MethodGet && resp.StatusCode == http.StatusOK {
head, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if readErr == nil && reader.BodyIsPlaylist(head, ct) {
// m3u8 判定不能只看 Content-Type 或 URL 后缀:上游经常把播放列表标成
// application/octet-stream,或者地址是 /index.m3u8?token=...(后缀判断不命中),
// 所以以 #EXTM3U 标记为准。
if probeBody && resp.StatusCode == http.StatusOK {
head := media.head
if reader.BodyIsPlaylist(head, ct) {
rest, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
rewritten := svc.Reader.RewritePlaylist(bookID, rawURL, string(head)+string(rest))
c.Data(http.StatusOK, "application/vnd.apple.mpegurl", []byte(rewritten))
return
}
// 不是播放列表:透传时 Content-Length 可能因为已经读过一部分而失配,
// 走到这里说明 head 不是错误页(fetchReaderMedia 已经把错误页换票重试过了),
// 原样拼回去继续流式透传。Content-Length 因为已读过一部分会失配,
// 直接省略交给 net/http 按 chunked 处理,避免少写/多写导致截断。
for _, h := range []string{"Content-Type", "Content-Range", "Accept-Ranges"} {
if v := resp.Header.Get(h); v != "" {
@@ -921,7 +970,7 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
if len(head) > 0 {
_, _ = c.Writer.Write(head)
}
_, _ = io.Copy(c.Writer, resp.Body)
streamReaderMediaBody(c.Writer, resp.Body)
return
}
// 流式透传(含 206 Partial Content,支持音频拖动进度)
@@ -931,13 +980,628 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
}
}
c.Status(resp.StatusCode)
_, _ = io.Copy(c.Writer, resp.Body)
streamReaderMediaBody(c.Writer, resp.Body)
}
}
// streamReaderMediaBody 把上游响应体写到客户端,并在上游中途断开时**主动掐断**连接。
//
// 这是修复「播放器拿到半首歌」的关键:以前这里 `_, _ = io.Copy(...)` 把错误直接
// 丢掉,上游(腾讯 CDN 边缘)在跨境链路上 RST 连接时,客户端收到的是一个正常
// 结束、但字节数不足的响应 —— 播放器无从判断,只能报
// ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED(看起来像「格式不支持」,实际是被截断)。
//
// 现在改成:上游没读完就中止这个响应,客户端因此拿到「传输不完整」而不是「正常结束」,
// 播放器可以按常规的重试/重连逻辑处理,而不是误判成格式问题。
func streamReaderMediaBody(w io.Writer, body io.Reader) {
if _, err := io.Copy(w, body); err != nil {
// panic(http.ErrAbortHandler) 是 net/http 约定的「异常中止」:不再补写
// chunked 结尾,直接关闭连接,客户端据此判定响应不完整。
panic(http.ErrAbortHandler)
}
}
// ─── 远端音轨落盘缓存 ──────────────────────────────────────────────────────
const (
// readerAudioCacheAttempts 首次落盘的最多尝试次数(每次内部都会走「失败换票」)。
// 续传只补没下完的部分,所以这里的次数比过去更可用:每次失败都能推进进度。
readerAudioCacheAttempts = 4
// readerMediaReissueAttempts 换票重试次数:跨境 CDN 的「空 401」是概率性的,
// 一张票不行往往换一张就好;旧实现只换一次,第二次失败就把 502 交给播放器。
readerMediaReissueAttempts = 3
readerMediaReissueDelay = 300 * time.Millisecond
)
// readerAudioFetchGroup 同一音轨并发请求(播放器 + 预取 + 预热)只下载一次。
var readerAudioFetchGroup singleflight.Group
// readerAudioStreamLocks 首播直通按缓存键串行化。
//
// 直通模式下两个并发请求会同时写同一个 .part 文件(播放器 + 预热器各自开一条
// 连接很常见),交错写入必然把文件写坏。这里让后到者排队等前一个下完,
// 拿到锁后再查一次缓存直接下发——语义与原来 singleflight 等价,只是领导者
// 是在流式外发的,追随者不需要重复下载。
var readerAudioStreamLocks = newKeyedMutex()
// keyedMutex 按字符串键串行化,锁释放后回收条目,避免 map 无限增长。
type keyedMutex struct {
mu sync.Mutex
locks map[string]*keyedMutexEntry
}
type keyedMutexEntry struct {
mu sync.Mutex
refs int
}
func newKeyedMutex() *keyedMutex {
return &keyedMutex{locks: map[string]*keyedMutexEntry{}}
}
// Lock 取得该键的独占锁,返回解锁函数(必须调用)。
func (k *keyedMutex) Lock(key string) func() {
k.mu.Lock()
entry := k.locks[key]
if entry == nil {
entry = &keyedMutexEntry{}
k.locks[key] = entry
}
entry.refs++
k.mu.Unlock()
entry.mu.Lock()
return func() {
entry.mu.Unlock()
k.mu.Lock()
entry.refs--
if entry.refs == 0 {
delete(k.locks, key)
}
k.mu.Unlock()
}
}
// handleCachedReaderAudio 音频直链的统一入口。
//
// 三条路径,按代价从低到高:
// 1. 命中完整缓存 → http.ServeContent 按 Range 下发(拖动、续播都读本地);
// 2. 冷启动首播 → 边下边发给客户端,同时落盘(DownloadRemoteAudioStreaming)。
// 旧实现是「整段下完再下发」,慢链路上用户要对着转圈等整章 3–5MB,
// 把「偶发失败」换成了「必然慢」;直通后几百毫秒就能出声,落盘照旧;
// 3. 已有 .part 分片 → 走整段下载:分片意味着上次已经下到一半,同步续传
// 剩余字节比重新直通更省流量,下完再一次性下发。
func handleCachedReaderAudio(
c *gin.Context,
svc *service.Container,
book *model.ReaderBook,
bookID, rawURL string,
headers map[string]string,
) {
if f, info, ok := svc.Reader.OpenCachedRemoteAudio(bookID, rawURL); ok {
defer f.Close()
serveCachedReaderAudio(c, f, info, rawURL)
return
}
// 播放器拖进度时会带 Range。已缓存的文件才谈得上 Range;未缓存时直通
// 只能从 0 发,否则客户端拿到的区间与它请求的对不上。
rangeHeader := strings.TrimSpace(c.GetHeader("Range"))
canStream := c.Request.Method == http.MethodGet &&
rangeHeader == "" &&
!svc.Reader.HasPartialRemoteAudio(bookID, rawURL)
if canStream {
handleStreamingReaderAudio(c, svc, book, bookID, rawURL, headers)
return
}
cachePath, err := ensureCachedRemoteAudio(c.Request.Context(), svc, book, bookID, rawURL, headers)
if err != nil {
if svc.Log != nil {
svc.Log.Warn("reader 音频拉取失败", zap.String("book", bookID), zap.Error(err))
}
c.String(http.StatusBadGateway, "音频拉取失败: %s", err.Error())
return
}
f, err := os.Open(cachePath) // #nosec G304 -- 路径由服务端哈希生成
if err != nil {
c.String(http.StatusBadGateway, "音频缓存不可用: %s", err.Error())
return
}
defer f.Close()
info, err := f.Stat()
if err != nil {
c.String(http.StatusBadGateway, "音频缓存不可用: %s", err.Error())
return
}
serveCachedReaderAudio(c, f, info, rawURL)
}
// handleStreamingReaderAudio 首播直通:一边落盘一边把字节写给客户端。
//
// 直接写 c.Writer 而不是先落盘再 ServeContent,是为了让第一个字节尽早到达。
// 下载失败时客户端已经收到部分字节,只能中止响应(见下面的 panic),
// 不能改成 502 —— 状态码早就发出去了。
func handleStreamingReaderAudio(
c *gin.Context,
svc *service.Container,
book *model.ReaderBook,
bookID, rawURL string,
headers map[string]string,
) {
key := svc.Reader.RemoteAudioCachePath(bookID, rawURL)
if key == "" {
c.String(http.StatusBadGateway, "音频缓存不可用")
return
}
unlock := readerAudioStreamLocks.Lock(key)
defer unlock()
// 排队期间前一个请求可能已经把整章下完:直接下发本地文件,不重复回源。
if f, info, ok := svc.Reader.OpenCachedRemoteAudio(bookID, rawURL); ok {
defer f.Close()
serveCachedReaderAudio(c, f, info, rawURL)
return
}
writer := c.Writer
// started 表示响应头是否已发出。没发出时失败还能改成 502;已经发出就只能
// 中止连接——状态码收不回来了。
started := false
_, err := svc.Reader.DownloadRemoteAudioStreaming(
c.Request.Context(), bookID, rawURL, readerAudioCacheAttempts,
readerAudioFetchFunc(c.Request.Context(), svc, book, bookID, rawURL, headers),
func(total int64, upstreamType string) {
contentType := readerAudioContentType(rawURL)
// 上游给了明确类型时优先用它(个别源把 m4a 标成 audio/x-m4a)。
if strings.HasPrefix(strings.ToLower(upstreamType), "audio/") {
contentType = upstreamType
}
writer.Header().Set("Content-Type", contentType)
writer.Header().Set("Cache-Control", "private, max-age=604800")
if total > 0 {
writer.Header().Set("Content-Length", strconv.FormatInt(total, 10))
}
writer.WriteHeader(http.StatusOK)
started = true
// 立刻把已写好的头刷给客户端:gin 的 ResponseWriter 有缓冲,
// 不 Flush 的话播放器要等到第一批音频字节才看到响应,白等一轮 RTT。
writer.Flush()
},
writer,
)
if err == nil {
return
}
if svc.Log != nil {
svc.Log.Warn("reader 音频直通失败", zap.String("book", bookID), zap.Error(err))
}
if !started {
// 一个字节都还没发出去(首拉就失败):保持与旧实现一致的错误语义。
c.String(http.StatusBadGateway, "音频拉取失败: %s", err.Error())
return
}
// 响应头已经发出:只能中止连接,让客户端判定「传输不完整」并自己重试。
// 保留 .part 分片,下一次请求会走续传路径。
panic(http.ErrAbortHandler)
}
// readerAudioFetchFunc 把「带换票重试的媒体拉取」适配成下载器需要的 fetcher。
func readerAudioFetchFunc(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
bookID, rawURL string,
headers map[string]string,
) reader.AudioRangeFetcher {
return func(rangeHeader string) (*http.Response, []byte, error) {
// 只有整段请求才需要嗅探响应体(判 HLS/错误页);续传拿到的是 206
// 二进制分片,读了还得再补写一次,没必要。
out, fetchErr := fetchReaderMediaResilient(ctx, svc, book, rawURL, rangeHeader, headers, rangeHeader == "")
if fetchErr != nil {
if isReaderMediaNoRetry(fetchErr) {
return nil, nil, &reader.NoRetryError{Err: fetchErr}
}
return nil, nil, fetchErr
}
return out.resp, out.head, nil
}
}
// serveCachedReaderAudio 用 http.ServeContent 下发本地音轨(自带 Range / 206 语义)。
func serveCachedReaderAudio(c *gin.Context, f *os.File, info os.FileInfo, rawURL string) {
c.Header("Content-Type", readerAudioContentType(rawURL))
c.Header("Cache-Control", "private, max-age=604800")
http.ServeContent(c.Writer, c.Request, "audio"+readerAudioExt(rawURL), info.ModTime(), f)
}
// ensureCachedRemoteAudio 单飞下载:并发命中同一章时只回源一次。
func ensureCachedRemoteAudio(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
bookID, rawURL string,
headers map[string]string,
) (string, error) {
key := svc.Reader.RemoteAudioCachePath(bookID, rawURL)
if key == "" {
return "", fmt.Errorf("音频缓存目录不可用")
}
v, err, _ := readerAudioFetchGroup.Do(key, func() (any, error) {
if svc.Reader.HasCachedRemoteAudio(bookID, rawURL) {
return key, nil
}
if err := downloadReaderAudioToCache(ctx, svc, book, bookID, rawURL, headers); err != nil {
return nil, err
}
return key, nil
})
if err != nil {
return "", err
}
path, _ := v.(string)
if path == "" {
return "", fmt.Errorf("音频缓存不可用")
}
return path, nil
}
// downloadReaderAudioToCache 整段拉取并落盘,支持断点续传:
// 上游 RST/超时后用 Range 从已写入的位置接着下,不再整段重下;
// 只有字节数与上游声明的总长一致时才会生成正式缓存文件。
func downloadReaderAudioToCache(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
bookID, rawURL string,
headers map[string]string,
) error {
_, err := svc.Reader.DownloadRemoteAudio(ctx, bookID, rawURL, readerAudioCacheAttempts,
readerAudioFetchFunc(ctx, svc, book, bookID, rawURL, headers))
return err
}
// isReaderMediaNoRetry 判断这次失败是否「换个花样重试也没用」。
// 用于让外层下载循环立刻收手,而不是把同一张被拒的票再打四遍。
func isReaderMediaNoRetry(err error) bool {
if errors.Is(err, errReaderMediaStaleTicket) {
return true
}
var rejected *readerMediaRejectedError
return errors.As(err, &rejected)
}
// readerAudioExt 取音频地址的后缀(用于推断 Content-Type)。
func readerAudioExt(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
return strings.ToLower(path.Ext(u.Path))
}
// readerAudioContentType 按后缀给出 Content-Type:缓存文件统一存成 .audio,
// 不能依赖 http.ServeContent 的嗅探,直接在响应头里写死。
func readerAudioContentType(rawURL string) string {
switch readerAudioExt(rawURL) {
case ".mp3":
return "audio/mpeg"
case ".aac":
return "audio/aac"
case ".flac":
return "audio/flac"
case ".wav":
return "audio/wav"
case ".ogg", ".oga", ".opus":
return "audio/ogg"
default:
// .m4a / .m4b / .wma / .ape 以及无后缀地址:腾讯的听书直链都是 m4a。
return "audio/mp4"
}
}
// readerImageDecodeMaxBytes 需要服务端解密的单张图片上限。
const readerImageDecodeMaxBytes = 32 << 20
// readerMediaProbeBytes 媒体代理嗅探上游响应体用的前缀长度。
//
// 这个前缀同时用于三件事:判定 HLS 播放列表(#EXTM3U)、判定「其实是错误页」、
// 以及把已读出的字节原样拼回响应体继续流式透传。
const readerMediaProbeBytes = 64 << 10
// readerMediaResult 一次媒体拉取的结果。
type readerMediaResult struct {
resp *http.Response
// url 是本次真正请求的媒体地址(换过票时是新票),m3u8 重写要用它做 base。
url string
// head 是已从响应体读出的前缀(仅 200 的 GET 才有),调用方写响应时要先写它。
head []byte
// rejected 表示 200 的响应体其实是错误页(HTML/JSON 报错),不能喂给播放器。
rejected bool
}
// fetchReaderMedia 拉取媒体,并在「签名票失效」时换票重试一次。
//
// 听书源的播放地址是第三方中转现签的 CDN 直链(vkey/uid 带时效),票被上游判掉时
// 有两种表现,这里都要兜住:
//
// ① 空的 401:没有 Content-Type、没有 body(腾讯 CDN 对无效 vkey/uid 的固定回答);
// ② 200 + HTML/JSON 错误页:中转自己出错时把它的报错页当成媒体回给我们。
//
// 旧行为是两种都原样透传给播放器:① 变成连续多个 401(播放器重试的是同一个
// 必然失败的地址),② 变成 ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED(19 个
// extractor 一个都不认)。两者的根因都是「这张票已经不能用了」,所以统一处理成
// 回源换一张新票再拉:
//
// - 换票成功 → 用新地址重拉,新票同时写回章节缓存,后续请求自然有效;
// - 地址不属于可换票的音轨(漫画图片防盗链的 403 等)→ 原样透传上游状态,
// 不改变原有行为;
// - 该换票但换不到、或换票后依然不可用 → 返回错误,由调用方给出明确文案,
// 而不是让播放器对着必然失败的地址反复重试。
//
// readerMediaRejectedError 上游拒绝了这张票,继续走同一条重试路径不会有进展。
//
// status —— 上游状态码(401/403/410)
// refreshed —— 这次拒绝发生在「已经换过一张新票」之后
// message —— 对外文案(留空时按状态码生成)
//
// 上游的拒绝常常成批出现(几分钟一波,随后自己恢复):换票拿到的是新票,但
// 同一时刻仍被同一个状态码挡回。连续两轮都这样时立刻停止重试——继续重试只会
// 让播放器多等十几秒,结果并不会更好。
type readerMediaRejectedError struct {
status int
refreshed bool
message string
}
func (e *readerMediaRejectedError) Error() string {
if e.message != "" {
return e.message
}
return fmt.Sprintf("音源暂时被上游拒绝(HTTP %d),请稍后重试或换源", e.status)
}
// errReaderMediaStaleTicket 换票没有拿到新票(书源还是给回同一张、或干脆给不出),
// 再重试同一个书源不会有不同结果。具体原因由外层用 %w 串上。
var errReaderMediaStaleTicket = errors.New("音源签名失效且重新取票失败")
func fetchReaderMedia(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
rawURL, rangeHeader string,
options map[string]string,
probeBody bool,
) (*readerMediaResult, error) {
// 上游 CDN 在并发/跨境链路上偶发 reset 连接(实测同一章重试即可成功),
// 探测阶段读到一半断开不能直接判死,先按退避重试几次。
out, refreshable, err := fetchReaderMediaWithRetry(ctx, svc, book, rawURL, rangeHeader, options, probeBody)
if err != nil || !refreshable {
return out, err
}
fresh, canRefresh, refreshErr := svc.Reader.RefreshMediaURL(ctx, book, rawURL)
if !canRefresh {
// 换不动(漫画图片防盗链的 403 等):原样透传上游状态,保持既有行为。
// 但有两类失败例外——它们透传给播放器毫无意义(播放器只会一直重试
// 同一个必然失败的地址,或者把网页当成音频),改成明确错误让前端提示。
switch {
case out.rejected:
_ = out.resp.Body.Close()
return nil, fmt.Errorf("音源返回了非媒体内容(%s),请稍后重试或换源",
strings.TrimSpace(out.resp.Header.Get("Content-Type")))
case out.resp.StatusCode == http.StatusUnauthorized:
_, _ = io.Copy(io.Discard, io.LimitReader(out.resp.Body, 1<<16))
_ = out.resp.Body.Close()
return nil, &readerMediaRejectedError{
status: http.StatusUnauthorized,
message: "音源签名失效(上游 401 拒绝),请稍后重试或换源",
}
}
return out, nil
}
// 换得动就必须先释放这张失败响应的连接,再发新请求。
_, _ = io.Copy(io.Discard, io.LimitReader(out.resp.Body, 1<<16))
_ = out.resp.Body.Close()
if refreshErr != nil {
return nil, fmt.Errorf("%w: %v", errReaderMediaStaleTicket, refreshErr)
}
retried, _, retryErr := fetchReaderMediaOnce(ctx, svc, book, fresh, rangeHeader, options, probeBody)
if retryErr != nil {
return nil, retryErr
}
if retried.rejected {
_ = retried.resp.Body.Close()
return nil, &readerMediaRejectedError{
status: retried.resp.StatusCode,
refreshed: true,
message: "音源换票后仍返回非媒体内容,请稍后重试或换源",
}
}
if reader.MediaNeedsRefresh(retried.resp.StatusCode) {
status := retried.resp.StatusCode
_ = retried.resp.Body.Close()
return nil, &readerMediaRejectedError{status: status, refreshed: true}
}
return retried, nil
}
// fetchReaderMediaResilient 在 fetchReaderMedia(已含一次换票)之上再包一层。
//
// 跨境 CDN 判掉签名票是概率性的:换一张票往往就能取到,而旧实现只换一次,
// 第二次失败直接把 502 交给播放器。这里最多再重来 readerMediaReissueAttempts 轮,
// 每轮都会作废章节缓存、重新回源签票。
//
// 但"没有进展"的失败会提前收手,否则用户要对着转圈干等三十多秒:
// - 换票拿不到新票(errReaderMediaStaleTicket)→ 立即结束;
// - 换过票仍是同一个拒绝状态码 → 两轮即结束(见 readerMediaRejectedError)。
func fetchReaderMediaResilient(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
rawURL, rangeHeader string,
options map[string]string,
probeBody bool,
) (*readerMediaResult, error) {
var lastErr error
lastRejected := 0
for attempt := 1; attempt <= readerMediaReissueAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(readerMediaReissueDelay * time.Duration(attempt-1)):
}
}
out, err := fetchReaderMedia(ctx, svc, book, rawURL, rangeHeader, options, probeBody)
if err == nil {
if out == nil || out.resp == nil {
lastErr = fmt.Errorf("音源响应为空")
continue
}
if !out.rejected && !reader.MediaNeedsRefresh(out.resp.StatusCode) {
return out, nil
}
status := out.resp.StatusCode
_ = out.resp.Body.Close()
lastErr = &readerMediaRejectedError{status: status, refreshed: true}
} else {
lastErr = err
if ctx.Err() != nil {
return nil, err
}
if errors.Is(err, errReaderMediaStaleTicket) {
return nil, err
}
}
var rejected *readerMediaRejectedError
if errors.As(lastErr, &rejected) {
// 没有换票空间的拒绝:原样重试同一张票没有意义。
if !rejected.refreshed {
return nil, lastErr
}
// 换过票还是同一个状态码:上游这几分钟就是不放行。
if lastRejected == rejected.status {
return nil, lastErr
}
lastRejected = rejected.status
}
}
return nil, lastErr
}
// readerMediaProbeAttempts 探测阶段遇到连接中断(reset/EOF)的最大尝试次数。
//
// 上游 CDN(腾讯 CDN 的跨境边缘)在并发或长传输时会直接 RST 连接,同一章
// 重试一次往往就好。以前这里读 body 失败就直接回 502,把一次可恢复的抖动
// 变成了「这本书播不了」。
const readerMediaProbeAttempts = 3
// readerMediaProbeRetryDelay 探测重试的退避基数。
const readerMediaProbeRetryDelay = 300 * time.Millisecond
// readerMediaProbeError 探测阶段的可重试错误(连接被 reset / 提前 EOF)。
type readerMediaProbeError struct{ err error }
func (e *readerMediaProbeError) Error() string { return e.err.Error() }
func (e *readerMediaProbeError) Unwrap() error { return e.err }
// fetchReaderMediaWithRetry 探测阶段按退避重试,只对「连接中断」类错误重试:
// 上游明确回了 4xx 时重试同一张票没有意义(那是换票流程的事)。
func fetchReaderMediaWithRetry(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
rawURL, rangeHeader string,
options map[string]string,
probeBody bool,
) (*readerMediaResult, bool, error) {
var lastErr error
for attempt := 1; attempt <= readerMediaProbeAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return nil, false, ctx.Err()
case <-time.After(readerMediaProbeRetryDelay * time.Duration(attempt-1)):
}
}
out, refreshable, err := fetchReaderMediaOnce(ctx, svc, book, rawURL, rangeHeader, options, probeBody)
if err == nil {
return out, refreshable, nil
}
var probeErr *readerMediaProbeError
if !errors.As(err, &probeErr) {
return nil, false, err
}
lastErr = err
}
return nil, false, lastErr
}
// fetchReaderMediaOnce 发一次媒体请求并做可用性判定。
//
// 返回的 refreshable 表示「这次失败值得换一张签名票再试」:
// 上游 401/403/410,或 200 但响应体其实是错误页(详见 fetchReaderMedia 的说明)。
// rejected 表示「200 但内容不是媒体」——调用方据此在无法换票时报明确错误,
// 而不是把网页喂给播放器。
func fetchReaderMediaOnce(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
rawURL, rangeHeader string,
options map[string]string,
probeBody bool,
) (*readerMediaResult, bool, error) {
resp, err := svc.Reader.FetchMediaWithOptions(ctx, book, rawURL, rangeHeader, options)
if err != nil {
return nil, false, err
}
if reader.MediaNeedsRefresh(resp.StatusCode) {
return &readerMediaResult{resp: resp, url: rawURL}, true, nil
}
if !probeBody || resp.StatusCode != http.StatusOK {
return &readerMediaResult{resp: resp, url: rawURL}, false, nil
}
// 先读一小段判断是不是错误页/播放列表;不是就原样拼回去继续流式透传。
head, readErr := io.ReadAll(io.LimitReader(resp.Body, readerMediaProbeBytes))
if readErr != nil {
_ = resp.Body.Close()
// 连接被 reset / 提前 EOF 属于上游抖动,交给上层退避重试。
return nil, false, &readerMediaProbeError{err: fmt.Errorf("读取媒体响应失败: %w", readErr)}
}
rejected := readerMediaPayloadRejected(head, resp.Header.Get("Content-Type"))
return &readerMediaResult{resp: resp, url: rawURL, head: head, rejected: rejected}, rejected, nil
}
// readerMediaPayloadRejected 判断上游 200 响应体其实是错误页而不是媒体。
//
// 直链失效的另一个变种:CDN/中转不返回 4xx,而是回一段 HTML 错误页或 JSON
// 报错(Content-Type 是 text/html、application/json)。这类字节喂给播放器,
// ExoPlayer 的 19 个 extractor 全都不认,报出来的是
// ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED —— 看起来像「格式不支持」,
// 实际是「拿到了一张网页」。这里按 Content-Type + 首字符识别出来,直接判失败。
func readerMediaPayloadRejected(head []byte, contentType string) bool {
if len(head) == 0 {
return false
}
lower := strings.ToLower(contentType)
errish := strings.Contains(lower, "html") ||
strings.Contains(lower, "json") ||
strings.HasPrefix(lower, "text/")
if !errish {
return false
}
trimmed := bytes.TrimSpace(head)
if len(trimmed) == 0 {
return false
}
switch trimmed[0] {
case '<', '{', '[':
return true
default:
return false
}
}
// isMediaPlaylistRequest 判断响应是否可能已是 HLS 播放列表。
// 播放列表太小、不该走图片解密分支(书源的 imageDecode 只会用于图片)。
func isMediaPlaylistRequest(resp *http.Response) bool {
@@ -1022,7 +1686,28 @@ func readerLocalAudioHandler(svc *service.Container) gin.HandlerFunc {
}
}
// readerContentOptions 从查询参数解析正文下发的可选项。
//
// 目前只有 `media` 一个维度:
// - 不传(网页端):媒体全部走服务端签名代理,与历史行为一致;
// - `media=direct`:书源开启直连时保留上游地址,由客户端自取;
// - `media=proxy`:本次强制代理,给客户端直连失败时的退路。
func readerContentOptions(c *gin.Context) reader.ContentOptions {
switch strings.ToLower(strings.TrimSpace(c.Query("media"))) {
case "direct":
return reader.ContentOptions{MediaDirect: true}
case "proxy":
return reader.ContentOptions{ForceProxy: true}
default:
return reader.ContentOptions{}
}
}
// readerBookContentHandler 书架维度正文(服务端应用替换净化规则)。
//
// media=direct 是客户端(Android App)专用的可选能力:媒体地址保留上游原始
// 地址并由客户端自行下载,服务端只下发所需请求头。不传该参数(网页端)时
// 行为与历史版本完全一致。
func readerBookContentHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
chapter, err := strconv.Atoi(c.DefaultQuery("chapter", "0"))
@@ -1031,7 +1716,8 @@ func readerBookContentHandler(svc *service.Container) gin.HandlerFunc {
return
}
userID := c.GetString(middleware.CtxUserID)
content, err := svc.Reader.GetContentForBook(c.Request.Context(), userID, c.Param("id"), chapter)
opts := readerContentOptions(c)
content, err := svc.Reader.GetContentForBook(c.Request.Context(), userID, c.Param("id"), chapter, opts)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -1044,13 +1730,14 @@ func readerBookContentHandler(svc *service.Container) gin.HandlerFunc {
//
// 请求 {chapter_indexes:[...], apply_replace:bool}:单章失败不中断整批,
// 返回每章内容与 hit/miss/failed 统计,供前端窗口预取与后续离线缓存。
// 媒体通道同样按 media=direct 查询参数选择,默认(网页端)走服务端代理。
func readerBookContentBatchHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
ChapterIndexes []int `json:"chapter_indexes" binding:"required"`
// ApplyReplace 是否在服务端应用用户替换净化规则(默认 false,与单章接口一致)。
ApplyReplace bool `json:"apply_replace"`
}
return func(c *gin.Context) {
var body struct {
ChapterIndexes []int `json:"chapter_indexes" binding:"required"`
// ApplyReplace 是否在服务端应用用户替换净化规则(默认 false,与单章接口一致)。
ApplyReplace bool `json:"apply_replace"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "chapter_indexes 不能为空"})
return
@@ -1060,7 +1747,7 @@ func readerBookContentBatchHandler(svc *service.Container) gin.HandlerFunc {
return
}
userID := c.GetString(middleware.CtxUserID)
result, err := svc.Reader.GetContentBatch(c.Request.Context(), userID, c.Param("id"), body.ChapterIndexes, body.ApplyReplace)
result, err := svc.Reader.GetContentBatch(c.Request.Context(), userID, c.Param("id"), body.ChapterIndexes, body.ApplyReplace, readerContentOptions(c))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
+3
View File
@@ -13,6 +13,8 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
admin := api.Group("/admin")
admin.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired())
registerAdminUserRoutes(admin, svc)
admin.GET("/tasks", backgroundTasksHandler(svc))
admin.GET("/quality", mediaQualityHandler(svc))
registerAdminPermissionRoutes(admin, svc)
registerAdminSystemRoutes(admin, svc)
registerAdminBackupRoutes(admin, svc)
@@ -143,6 +145,7 @@ func registerAdminSystemRoutes(admin *gin.RouterGroup, svc *service.Container) {
func registerAdminBackupRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.GET("/backups", listBackupsHandler(svc))
admin.POST("/backups", createBackupHandler(svc))
admin.GET("/backups/download", downloadBackupHandler(svc))
admin.DELETE("/backups", deleteBackupHandler(svc))
admin.POST("/backups/restore", restoreBackupHandler(svc))
}
@@ -36,6 +36,9 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
authed.GET("/libraries", listLibrariesHandler(svc))
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
authed.GET("/libraries/:id", getLibraryHandler(svc))
// 服务端生成的媒体库封面(成员海报拼图),Web / App / Emby 共用同一份。
authed.GET("/libraries/:id/cover", libraryCoverHandler(svc))
authed.HEAD("/libraries/:id/cover", libraryCoverHandler(svc))
authed.PATCH("/libraries/:id", middleware.AdminRequired(), updateLibraryHandler(svc))
authed.PUT("/libraries/reorder", middleware.AdminRequired(), reorderLibrariesHandler(svc))
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
@@ -74,6 +74,7 @@ func registerAuthedPlaybackExtraRoutes(authed *gin.RouterGroup, svc *service.Con
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
authed.GET("/playback/:id/resume", playbackResumeHandler(svc))
authed.GET("/playback/:id/segments", playbackSegmentsHandler(svc))
authed.PUT("/playback/:id/segments", playbackSegmentsMarkHandler(svc))
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
@@ -21,6 +21,8 @@ func registerAuthedDLNARoutes(authed *gin.RouterGroup, svc *service.Container) {
}
func registerAuthedRealtimeRoutes(authed *gin.RouterGroup, svc *service.Container) {
authed.GET("/ws", wsHandler(svc))
authed.GET("/events", sseHandler(svc))
// These streams carry operator tasks, including filesystem paths and scan
// errors. Their payloads are not scoped to an individual user's libraries.
authed.GET("/ws", middleware.AdminRequired(), wsHandler(svc))
authed.GET("/events", middleware.AdminRequired(), sseHandler(svc))
}
+43 -3
View File
@@ -27,7 +27,15 @@ func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"))
scope, ok := localHLSPlaybackScope(c)
if !ok {
return
}
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"), scope)
if errors.Is(err, service.ErrHLSGenerationSuperseded) {
c.JSON(http.StatusConflict, gin.H{"code": "hls_generation_superseded", "error": err.Error()})
return
}
if errors.Is(err, service.ErrMediaNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
@@ -62,7 +70,11 @@ func hlsSegmentHandler(svc *service.Container) gin.HandlerFunc {
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
err = svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg"))
scope, ok := localHLSPlaybackScope(c)
if !ok {
return
}
err = svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg"), scope)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
@@ -72,11 +84,39 @@ func hlsSegmentHandler(svc *service.Container) gin.HandlerFunc {
func stopTranscodeHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
svc.Transcoder.StopJob(c.Param("id"))
m, err := findMediaForPlaybackEndpoint(c, svc, c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
scope, ok := localHLSPlaybackScope(c)
if !ok {
return
}
if svc.Transcoder != nil {
svc.Transcoder.StopPlayback(m.ID, scope)
}
c.Status(http.StatusNoContent)
}
}
func localHLSPlaybackScope(c *gin.Context) (service.HLSPlaybackScope, bool) {
userID := currentUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing playback owner"})
return service.HLSPlaybackScope{}, false
}
scope, err := service.HLSPlaybackScopeForRequest(c.Request, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return service.HLSPlaybackScope{}, false
}
return scope, true
}
func imageProxyHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
raw := c.Query("url")
+14
View File
@@ -27,6 +27,10 @@ import (
// historyListHandler returns the caller's history rows joined with the
// matching media in a single response.
//
// include_media=0 只返回历史行本身。客户端判断「继续播放」指向哪一集时只
// 需要 media_id/position_ms;带上整条 media 会让 300 条的响应从 ~40KB 涨到
// ~420KB,手机链路上光传输就要十几秒,详情页的续播解析会被它拖住。
func historyListHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
@@ -34,6 +38,12 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc {
if limit <= 0 || limit > 500 {
limit = 50
}
includeMedia := true
if raw := strings.TrimSpace(c.Query("include_media")); raw != "" {
if parsed, err := strconv.ParseBool(raw); err == nil {
includeMedia = parsed
}
}
items, err := svc.Playback.RecentHistory(c.Request.Context(), toString(uid), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -42,7 +52,11 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc {
visibility := mediaVisibilityForRequest(c, svc)
filtered := make([]service.HistoryItem, 0, len(items))
for _, item := range items {
// 可见性仍然按 media 判断,只是决定要不要把它写进响应。
if item.Media == nil || visibility.Allows(item.Media) {
if !includeMedia {
item.Media = nil
}
filtered = append(filtered, item)
}
}
+72
View File
@@ -103,3 +103,75 @@ func TestHistoryContinueRemovesMissingMedia(t *testing.T) {
t.Fatalf("stale history rows remaining = %d, want 0", staleCount)
}
}
// include_media=0 只是不把 media 写进响应,行数和可见性判断都不变。
func TestHistoryListCanOmitEmbeddedMedia(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.PlaybackHistory{}, &model.Media{}); err != nil {
t.Fatal(err)
}
media := model.Media{
Base: model.Base{ID: "media-1"},
Title: "影片",
Path: "/media/one.mkv",
}
if err := db.Create(&media).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "history-1"},
UserID: "user-1",
MediaID: media.ID,
PositionMs: 30_000,
DurationMs: 120_000,
WatchedAt: time.Now(),
}).Error; err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := &service.Container{
Log: zap.NewNop(),
Repo: repos,
Playback: service.NewPlaybackService(zap.NewNop(), repos),
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, "user-1")
c.Next()
})
router.GET("/watch-history", historyListHandler(svc))
type listRow struct {
MediaID string `json:"media_id"`
Media *model.Media `json:"media"`
}
full := httptest.NewRecorder()
router.ServeHTTP(full, httptest.NewRequest(http.MethodGet, "/watch-history?limit=10", nil))
var fullRows []listRow
if err := json.Unmarshal(full.Body.Bytes(), &fullRows); err != nil {
t.Fatal(err)
}
if len(fullRows) != 1 || fullRows[0].Media == nil || fullRows[0].Media.ID != media.ID {
t.Fatalf("default response should embed media: %s", full.Body.String())
}
lean := httptest.NewRecorder()
router.ServeHTTP(lean, httptest.NewRequest(http.MethodGet, "/watch-history?limit=10&include_media=0", nil))
var leanRows []listRow
if err := json.Unmarshal(lean.Body.Bytes(), &leanRows); err != nil {
t.Fatal(err)
}
if len(leanRows) != 1 || leanRows[0].MediaID != media.ID {
t.Fatalf("lean response should keep the row: %s", lean.Body.String())
}
if leanRows[0].Media != nil {
t.Fatalf("include_media=0 must not embed media: %s", lean.Body.String())
}
}
+8 -5
View File
@@ -1,9 +1,7 @@
// Package handler — WebSocket endpoint.
//
// Clients connect to /api/ws?token=... (the token is the same JWT used for
// REST calls). The first message they send is a JSON {"action":"subscribe",
// "topics":["scan","scrape","transcode"]}. Subsequent server-pushed events
// arrive as {"topic":"...","payload":{...}}.
// Administrators connect to /api/ws?token=... with their REST access token.
// Operator events arrive as {"topic":"...","payload":{...}}.
package handler
import (
@@ -52,8 +50,11 @@ func wsHandler(svc *service.Container) gin.HandlerFunc {
sub := svc.WSHub.Subscribe(id, nil)
defer svc.WSHub.Unsubscribe(id)
// Reader: accept ping + subscription updates.
// Reading processes WebSocket control frames and detects disconnects.
done := make(chan struct{})
conn.SetReadLimit(64 << 10)
go func() {
defer close(done)
for {
if _, _, err := conn.NextReader(); err != nil {
_ = conn.Close()
@@ -67,6 +68,8 @@ func wsHandler(svc *service.Container) gin.HandlerFunc {
defer ticker.Stop()
for {
select {
case <-done:
return
case ev, ok := <-sub.Out:
if !ok {
return
+93
View File
@@ -0,0 +1,93 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/websocket"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
)
func realtimeTestRouter(hub *service.Hub) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
authed := router.Group("/api")
authed.Use(middleware.AuthRequired("realtime-test"))
registerAuthedRealtimeRoutes(authed, &service.Container{WSHub: hub})
return router
}
func realtimeTestToken(t *testing.T, role string) string {
t.Helper()
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, middleware.Claims{
UserID: "viewer", Role: role,
RegisteredClaims: jwt.RegisteredClaims{ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour))},
}).SignedString([]byte("realtime-test"))
if err != nil {
t.Fatal(err)
}
return token
}
func TestRealtimeStreamsRejectOrdinaryUsers(t *testing.T) {
router := realtimeTestRouter(nil)
for _, path := range []string{"/api/ws", "/api/events"} {
for _, role := range []string{"", "user"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
want := http.StatusUnauthorized
if role != "" {
req.Header.Set("Authorization", "Bearer "+realtimeTestToken(t, role))
want = http.StatusForbidden
}
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
if recorder.Code != want {
t.Fatalf("%s role %q: status %d, want %d", path, role, recorder.Code, want)
}
}
}
}
func TestAdminWebSocketReceivesTaskEvents(t *testing.T) {
hub := service.NewHub(zap.NewNop())
go hub.Run()
defer hub.Stop()
server := httptest.NewServer(realtimeTestRouter(hub))
defer server.Close()
url := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/ws?token=" + realtimeTestToken(t, "admin")
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
stop := make(chan struct{})
defer close(stop)
go func() {
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
hub.Publish("task", service.BackgroundTask{ID: "task-1", SourcePath: "/media/private"})
}
}
}()
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
var event service.Event
if err := conn.ReadJSON(&event); err != nil {
t.Fatal(err)
}
if event.Topic != "task" || event.Payload.(map[string]any)["source_path"] != "/media/private" {
t.Fatalf("event = %#v", event)
}
}
+43 -4
View File
@@ -7,6 +7,7 @@ import (
"compress/flate"
"compress/gzip"
"compress/zlib"
"crypto/tls"
"io"
"net/http"
"net/url"
@@ -88,10 +89,51 @@ func StripAcceptEncoding(h http.Header) {
//
// When useProxy is false, the client is created without proxy plumbing so
// the request goes out direct, matching the user's checkbox intent.
//
// 注意 http.Client.Timeout 是「整个请求」的上限,包含读完响应体。书源的
// 搜索/目录/正文都是小响应,30 秒足够;但同一个 client 也被用来流转发
// 音频/视频(见 NewMediaHTTPClient),那里必须换一个没有整体超时的 client。
func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
if timeoutSeconds <= 0 {
timeoutSeconds = 15
}
return &http.Client{
Timeout: time.Duration(timeoutSeconds) * time.Second,
Transport: newSiteTransport(useProxy),
}
}
// NewMediaHTTPClient 拉取并转发媒体流(音频/视频/大图)的客户端。
//
// 为什么不能用 NewSiteHTTPClient:http.Client.Timeout 会覆盖「读完 body」这个
// 阶段。一个 4MB 的 m4a 在跨境链路上很容易超过 30 秒,一旦超时,net/http 会
// 直接掐断响应体,而调用方的 io.Copy 只是正常返回 —— 表现为**静默截断**
// (播放器拿到半首歌,或判「格式不支持」),或者上游慢到 Do 本身报错 → 502。
//
// 为什么强制 HTTP/1.1:实测腾讯 CDN(vb.stream.tencentmusic.com)的 h2 边缘在
// 长连接大文件上不按预期回 WINDOW_UPDATE,Go 的 http2 Transport 初始连接窗口
// 是 1MiB,于是下载**稳定卡在 1MiB/2MiB/3MiB 整数倍**处断开;同一 URL 用
// HTTP/1.1(wget、curl --http1.1)从头到尾都完整。因此媒体通路固定走 h1.1:
// 音视频本来就没有「多路复用省请求」的收益,反而是流控 bug 的受害者。
func NewMediaHTTPClient(useProxy bool) *http.Client {
tr := newSiteTransport(useProxy)
// 等响应头最多 90 秒:跨境 CDN 首包偶发要几十秒,但绝不能无上限挂着。
tr.ResponseHeaderTimeout = 90 * time.Second
// 媒体是长连接大流量,连接池开大一些,避免并发取图/拖动进度时互相排队。
tr.MaxIdleConns = 32
tr.MaxIdleConnsPerHost = 16
// 关闭 HTTP/2:见上面的流控说明。
tr.ForceAttemptHTTP2 = false
tr.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{}
// 不复用连接:实测腾讯 CDN 的边缘在「同一条 keep-alive 连接上连续取多个
// 大对象」时会直接 RST 第二个请求的响应体(容器内 wget/curl 每次新建连接
// 就完全正常)。音视频请求本来就稀疏,为省一次 TLS 握手去冒截断风险不划算。
tr.DisableKeepAlives = true
return &http.Client{Transport: tr}
}
// newSiteTransport 书源请求共用的 Transport(媒体与规则请求配置一致的部分)。
func newSiteTransport(useProxy bool) *http.Transport {
tr := &http.Transport{
MaxIdleConns: 16,
MaxIdleConnsPerHost: 4,
@@ -104,10 +146,7 @@ func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
return ProxyFromEnvironmentOrSystem(r)
}
}
return &http.Client{
Timeout: time.Duration(timeoutSeconds) * time.Second,
Transport: tr,
}
return tr
}
// HTTPHeaderPresets returns a map of realistic browser HTTP headers.
+12
View File
@@ -17,12 +17,24 @@ var gzipExcludedExtensions = []string{
// apiGzipExcludedPrefixes 大文件流式传输(Range 语义)与 WS/SSE 长连接
// 不参与 gzip:压缩会破坏 Range / 逐块推送语义。
//
// 阅读媒体代理(/api/reader/media、/api/reader/local/audio、
// /api/reader/audio/transcode)同样必须排除,原因有两条:
// - 它们透传上游的 206 Partial Content。gin-contrib/gzip 在压缩时会删掉
// Content-Length,却把 Content-Range 原样留下,客户端看到的是「声明
// N 字节、实际收到 M 字节的 gzip 流」,自己发 Accept-Encoding 的播放器
// (ExoPlayer 不透明处理)直接判 ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED。
// - 音视频本来就是压缩格式,再 gzip 一遍只烧 CPU(3.5MB 的 m4a 压完还是
// 3.5MB 量级),对带宽没有收益。
var apiGzipExcludedPrefixes = []string{
"/api/stream/",
"/api/hls/",
"/api/img",
"/api/subtitles/",
"/api/strm/play/",
"/api/reader/media",
"/api/reader/local/audio",
"/api/reader/audio/transcode",
"/api/ws",
"/api/events",
}
+46
View File
@@ -70,6 +70,52 @@ func TestGzipAPIExcludesStreamPath(t *testing.T) {
}
}
// TestGzipAPIExcludesReaderMedia 阅读媒体代理必须排除在 gzip 之外。
//
// 它会透传 CDN 的 206 Partial Content。gin-contrib/gzip 压缩时会删掉
// Content-Length 却保留 Content-Range,客户端看到「声明 N 字节、实收 M 字节」,
// 自己发 Accept-Encoding 的播放器(ExoPlayer)直接判
// ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,表现为「音频格式不支持」。
func TestGzipAPIExcludesReaderMedia(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api")
api.Use(GzipAPI())
// 用 206 + Content-Range 模拟真实的音频分段响应。
segment := func(c *gin.Context) {
c.Header("Content-Range", "bytes 0-99/100")
c.Data(http.StatusPartialContent, "audio/mp4", []byte(strings.Repeat("x", 4096)))
}
for _, path := range []string{
"/reader/media",
"/reader/local/audio",
"/reader/audio/transcode",
} {
api.GET(path, segment)
}
for _, path := range []string{
"/reader/media",
"/reader/local/audio",
"/reader/audio/transcode",
} {
req := httptest.NewRequest(http.MethodGet, "/api"+path+"?b=book&u=x&s=y", nil)
req.Header.Set("Accept-Encoding", "gzip")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if got := w.Header().Get("Content-Encoding"); got == "gzip" {
t.Errorf("%s 不该被 gzip(Range 语义会被破坏)", path)
}
if got := w.Header().Get("Content-Range"); got != "bytes 0-99/100" {
t.Errorf("%s 的 Content-Range 被破坏: %q", path, got)
}
if w.Body.Len() != 4096 {
t.Errorf("%s 响应体长度 = %d,期望 4096", path, w.Body.Len())
}
}
}
func TestGzipStaticCompressesAssets(t *testing.T) {
router := newGzipTestRouter()
+23
View File
@@ -0,0 +1,23 @@
package model
import "time"
// BackgroundTaskRecord persists the operator-facing lifecycle of scan,
// organize, and other long-running application tasks.
type BackgroundTaskRecord struct {
ID string `gorm:"primaryKey;size:36" json:"id"`
Kind string `gorm:"index;size:32;not null" json:"kind"`
Name string `gorm:"size:255;not null" json:"name"`
Status string `gorm:"index;size:24;not null" json:"status"`
Stage string `gorm:"size:64" json:"stage"`
SourcePath string `gorm:"size:2048" json:"source_path"`
DestPath string `gorm:"size:2048" json:"dest_path"`
Message string `gorm:"type:text" json:"message"`
Error string `gorm:"type:text" json:"error"`
Details string `gorm:"type:text" json:"details"`
Metrics string `gorm:"type:text" json:"metrics"`
Items string `gorm:"type:text" json:"items"`
StartedAt time.Time `gorm:"index" json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
}
+11 -6
View File
@@ -1,5 +1,7 @@
package model
import "time"
// Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。
type Library struct {
Base
@@ -26,12 +28,15 @@ type LibraryRoot struct {
// Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。
type Media struct {
Base
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
Title string `gorm:"size:255;not null" json:"title"`
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
AvailabilityState string `gorm:"index;size:16;not null;default:available" json:"availability_state"`
MissingSince *time.Time `json:"missing_since,omitempty"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
Title string `gorm:"size:255;not null" json:"title"`
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
// Path 的唯一性由 database 包按方言显式建索引,这里刻意不用 uniqueIndex:
// PostgreSQL 的 btree 单条索引项上限 2704 字节,而 varchar(1024) 在中文路径
// 下可达 3072 字节,直接对整列建唯一索引会让长路径的扫描插入直接失败
+1
View File
@@ -61,6 +61,7 @@ func AllModels() []interface{} {
&StrmUploadTask{},
&StrmDirCache{},
&ScrapeTask{},
&BackgroundTaskRecord{},
&EmbyMount{},
&ReaderBookSource{},
&ReaderSourceState{},
+6
View File
@@ -38,6 +38,12 @@ type ReaderBookSource struct {
// NeedsBrowser 是否依赖 WebView/无头浏览器(webView 选项或 webjs 规则)。
// 服务端没有浏览器,这类源注定不可用,列表接口按需计算让前端能提前提示。
NeedsBrowser bool `gorm:"-" json:"needs_browser"`
// MediaDirect 允许客户端直连上游取实体媒体(音频/漫画图片),服务端只负责
// 解析地址与下发所需请求头。
//
// 只在客户端显式请求直连时生效(见 ContentOptions):网页端不声明该能力,
// 行为与开启此字段前完全一致。
MediaDirect bool `gorm:"default:false" json:"media_direct"`
}
// ReaderSourceState 书源会话状态:对应 legado 中按书源 key 存储的
+8 -10
View File
@@ -178,14 +178,6 @@ func (r *MediaRepository) YearRange(ctx context.Context, filter MediaQueryFilter
return min, max, nil
}
func (r *MediaRepository) indexMediaBestEffort(ctx context.Context, media model.Media) {
backend, ok := r.searchBackend.(MediaSearchSyncBackend)
if !ok {
return
}
_ = backend.IndexMedia(ctx, []model.Media{media})
}
// FindByID returns the media row or (nil, nil).
func (r *MediaRepository) FindByID(ctx context.Context, id string) (*model.Media, error) {
var m model.Media
@@ -245,10 +237,16 @@ func (r *MediaRepository) ListSeasonSiblings(ctx context.Context, m *model.Media
} else {
libraryID := strings.TrimSpace(m.LibraryID)
title := strings.TrimSpace(m.Title)
if libraryID == "" || title == "" || m.TMDbID <= 0 {
if libraryID == "" || title == "" {
return nil, nil
}
query = query.Where("library_id = ? AND title = ? AND tm_db_id = ?", libraryID, title, m.TMDbID)
// 没有 series_id 也没有 TMDb id 时,退到「同库 + 同标题」:手动标记的按季
// 传播是用户显式操作,同库同名已经足够可靠;而在缺 id 时直接放弃会让这个
// 功能在未刮削的库上完全不生效。
query = query.Where("library_id = ? AND title = ?", libraryID, title)
if m.TMDbID > 0 {
query = query.Where("tm_db_id = ?", m.TMDbID)
}
}
var rows []model.Media
if err := query.Find(&rows).Error; err != nil {
+38 -3
View File
@@ -5,6 +5,7 @@ import (
"errors"
"path/filepath"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -96,15 +97,34 @@ func (r *MediaRepository) UpsertBatchWithAliases(ctx context.Context, items []Me
// indexByIDBestEffort 在事务提交后按 ID 回读最新行并同步搜索索引。
func (r *MediaRepository) indexByIDBestEffort(ctx context.Context, ids []string) {
if _, ok := r.searchBackend.(MediaSearchSyncBackend); !ok {
backend, ok := r.searchBackend.(MediaSearchSyncBackend)
if !ok {
return
}
unique := make([]string, 0, len(ids))
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {
if id == "" {
continue
}
if fresh, err := r.FindByID(ctx, id); err == nil && fresh != nil {
r.indexMediaBestEffort(ctx, *fresh)
if _, exists := seen[id]; !exists {
seen[id] = struct{}{}
unique = append(unique, id)
}
}
// Read committed rows and submit one bulk per bounded batch. Keep network
// work outside the transaction and below SQLite's parameter limit.
const batchSize = 500
for start := 0; start < len(unique); start += batchSize {
batch := unique[start:min(start+batchSize, len(unique))]
rows, err := r.ListMediaByIDs(ctx, batch)
if err == nil {
err = backend.IndexMedia(ctx, rows)
}
if err != nil {
// The primary write has committed; do not report a failed upsert and
// make scanners repeat it. Surface the separate index failure instead.
r.db.Logger.Error(ctx, "media saved but search index sync failed for %d items: %v", len(batch), err)
}
}
}
@@ -144,6 +164,12 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
existing.Path = m.Path
existing.DeletedAt = gorm.DeletedAt{}
}
if _, ok := updates["availability_state"]; ok {
existing.AvailabilityState = "available"
existing.MissingSince = nil
now := updates["last_seen_at"].(time.Time)
existing.LastSeenAt = &now
}
*m = existing
return existing.ID, nil
}
@@ -197,6 +223,10 @@ func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.
if m.ScrapeStatus == "" {
m.ScrapeStatus = "pending"
}
now := time.Now()
m.AvailabilityState = "available"
m.MissingSince = nil
m.LastSeenAt = &now
// PostgreSQL 唯一键异常会中止整个事务,之后重查也会失败。
// DO NOTHING 保持事务可用,并兼容 path 的表达式唯一索引。
result := db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(m)
@@ -246,6 +276,11 @@ func (r *MediaRepository) findMediaByAlias(ctx context.Context, db *gorm.DB, cur
func mediaUpsertUpdates(existing, incoming model.Media, reclaimLibrary bool) map[string]any {
updates := map[string]any{}
if existing.AvailabilityState != "available" || existing.MissingSince != nil {
updates["availability_state"] = "available"
updates["missing_since"] = nil
updates["last_seen_at"] = time.Now()
}
addMediaFileScanUpdates(updates, existing, incoming)
addMediaTitleUpdates(updates, existing, incoming)
addMediaExternalIDUpdates(updates, existing, incoming)
@@ -56,6 +56,23 @@ func (r *MediaSegmentRepository) ReplaceForMedia(ctx context.Context, mediaID, s
})
}
// ReplaceForMediaSourceKind swaps the segments of one (media, source, kind)
// triple in a single transaction. Used by manual marking, where the user sets
// one boundary at a time and must not disturb the other kinds.
func (r *MediaSegmentRepository) ReplaceForMediaSourceKind(ctx context.Context, mediaID, source, kind string, rows []model.MediaSegment) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().
Where("media_id = ? AND source = ? AND kind = ?", mediaID, source, kind).
Delete(&model.MediaSegment{}).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
return tx.Create(&rows).Error
})
}
// GetFetch returns the fetch ledger row for (media, source), or (nil, nil).
func (r *MediaSegmentRepository) GetFetch(ctx context.Context, mediaID, source string) (*model.MediaSegmentFetch, error) {
var row model.MediaSegmentFetch
+119 -4
View File
@@ -4,8 +4,10 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
@@ -135,14 +137,113 @@ func (b *OpenSearchMediaBackend) EnsureIndex(ctx context.Context) error {
}
func (b *OpenSearchMediaBackend) IndexMedia(ctx context.Context, rows []model.Media) error {
const batchSize = 500
var failures []error
for start := 0; start < len(rows); start += batchSize {
if err := b.indexMediaBatch(ctx, rows[start:min(start+batchSize, len(rows))]); err != nil {
failures = append(failures, err)
if ctx.Err() != nil {
break
}
}
}
return errors.Join(failures...)
}
type openSearchBulkResponse struct {
Errors bool `json:"errors"`
Items []struct {
Index struct {
ID string `json:"_id"`
Status int `json:"status"`
Error *struct {
Type string `json:"type"`
} `json:"error"`
} `json:"index"`
} `json:"items"`
}
func (b *OpenSearchMediaBackend) indexMediaBatch(ctx context.Context, rows []model.Media) error {
if len(rows) == 0 {
return nil
}
pending := rows
var failures []error
const attempts = 3
for attempt := 0; attempt < attempts; attempt++ {
if err := ctx.Err(); err != nil {
return errors.Join(append(failures, err)...)
}
if attempt > 0 {
timer := time.NewTimer(time.Duration(100*(1<<(attempt-1))) * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return errors.Join(append(failures, ctx.Err())...)
case <-timer.C:
}
}
response, err := b.submitMediaBulk(ctx, pending)
if err != nil {
if retryableOpenSearchError(err) && attempt < attempts-1 {
continue
}
return errors.Join(append(failures, err)...)
}
if len(response.Items) != len(pending) {
return errors.Join(append(failures, fmt.Errorf("opensearch bulk returned %d item results for %d documents", len(response.Items), len(pending)))...)
}
var retry []model.Media
failed := false
for i, item := range response.Items {
result := item.Index
if result.Status >= 200 && result.Status < 300 && result.Error == nil {
continue
}
failed = true
if retryableOpenSearchStatus(result.Status) && attempt < attempts-1 {
retry = append(retry, pending[i])
continue
}
errorType := "unknown"
if result.Error != nil {
errorType = result.Error.Type
}
failures = append(failures, fmt.Errorf("opensearch bulk document %q failed: status %d (%s)", pending[i].ID, result.Status, errorType))
}
if response.Errors && !failed {
failures = append(failures, errors.New("opensearch bulk reported errors without failed item results"))
}
if len(retry) == 0 {
break
}
pending = retry
}
return errors.Join(failures...)
}
func retryableOpenSearchStatus(status int) bool {
return status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status >= 500
}
func retryableOpenSearchError(err error) bool {
var statusError *openSearchHTTPError
if errors.As(err, &statusError) {
return retryableOpenSearchStatus(statusError.status)
}
var networkError net.Error
return errors.As(err, &networkError)
}
func (b *OpenSearchMediaBackend) submitMediaBulk(ctx context.Context, rows []model.Media) (*openSearchBulkResponse, error) {
if len(rows) == 0 {
return &openSearchBulkResponse{}, nil
}
var bulk bytes.Buffer
enc := json.NewEncoder(&bulk)
for _, row := range rows {
if err := enc.Encode(map[string]any{"index": map[string]any{"_index": b.index, "_id": row.ID}}); err != nil {
return err
return nil, err
}
if err := enc.Encode(map[string]any{
"id": row.ID,
@@ -155,10 +256,24 @@ func (b *OpenSearchMediaBackend) IndexMedia(ctx context.Context, rows []model.Me
"deleted": row.DeletedAt.Valid,
"created_at": row.CreatedAt,
}); err != nil {
return err
return nil, err
}
}
return b.do(ctx, http.MethodPost, "/_bulk", &bulk, "application/x-ndjson", nil)
var response openSearchBulkResponse
if err := b.do(ctx, http.MethodPost, "/_bulk", &bulk, "application/x-ndjson", &response); err != nil {
return nil, err
}
return &response, nil
}
type openSearchHTTPError struct {
method string
path string
status int
}
func (e *openSearchHTTPError) Error() string {
return fmt.Sprintf("opensearch %s %s returned %d", e.method, e.path, e.status)
}
func (b *OpenSearchMediaBackend) doJSON(ctx context.Context, method, path string, body any, out any) error {
@@ -190,7 +305,7 @@ func (b *OpenSearchMediaBackend) do(ctx context.Context, method, path string, bo
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("opensearch %s %s returned %d", method, path, resp.StatusCode)
return &openSearchHTTPError{method: method, path: path, status: resp.StatusCode}
}
if out == nil {
_, _ = io.Copy(io.Discard, resp.Body)
+164
View File
@@ -1,12 +1,23 @@
package repository
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
func TestOpenSearchMediaBackendSearchesIDs(t *testing.T) {
@@ -55,3 +66,156 @@ func TestOpenSearchMediaBackendSearchesIDs(t *testing.T) {
t.Fatalf("ids=%#v total=%d", ids, total)
}
}
func bulkRequestIDs(r *http.Request) []string {
var ids []string
scanner := bufio.NewScanner(r.Body)
for scanner.Scan() {
var action struct {
Index struct {
ID string `json:"_id"`
} `json:"index"`
}
_ = json.Unmarshal(scanner.Bytes(), &action)
ids = append(ids, action.Index.ID)
if !scanner.Scan() {
break
}
}
return ids
}
func writeBulkResults(w http.ResponseWriter, ids []string, statuses map[string]int) {
items := make([]any, 0, len(ids))
failed := false
for _, id := range ids {
status := statuses[id]
if status == 0 {
status = http.StatusCreated
}
result := map[string]any{"_id": id, "status": status}
if status >= 400 {
failed = true
result["error"] = map[string]any{"type": "test_failure"}
}
items = append(items, map[string]any{"index": result})
}
_ = json.NewEncoder(w).Encode(map[string]any{"errors": failed, "items": items})
}
func TestOpenSearchBulkRetriesOnlyTransientFailedDocuments(t *testing.T) {
var requests [][]string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ids := bulkRequestIDs(r)
requests = append(requests, ids)
statuses := map[string]int{}
if len(requests) == 1 {
statuses["retry"] = http.StatusTooManyRequests
statuses["invalid"] = http.StatusBadRequest
}
writeBulkResults(w, ids, statuses)
}))
defer upstream.Close()
backend := NewOpenSearchMediaBackend(config.SearchConfig{Backend: "opensearch", OpenSearchURL: upstream.URL})
rows := []model.Media{{Base: model.Base{ID: "ok"}}, {Base: model.Base{ID: "retry"}}, {Base: model.Base{ID: "invalid"}}}
err := backend.IndexMedia(t.Context(), rows)
if err == nil || !strings.Contains(err.Error(), `"invalid"`) {
t.Fatalf("error = %v, want permanent document failure", err)
}
if len(requests) != 2 || strings.Join(requests[1], ",") != "retry" {
t.Fatalf("requests = %#v; successful/permanent documents must not be retried", requests)
}
}
func TestOpenSearchBulkBoundsRetriesAndRejectsMalformedResponses(t *testing.T) {
for _, scenario := range []string{"transient-item", "transient-http", "invalid-http", "missing-results"} {
t.Run(scenario, func(t *testing.T) {
calls := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
switch scenario {
case "transient-item":
writeBulkResults(w, bulkRequestIDs(r), map[string]int{"m1": http.StatusServiceUnavailable})
case "transient-http":
w.WriteHeader(http.StatusServiceUnavailable)
case "invalid-http":
w.WriteHeader(http.StatusBadRequest)
case "missing-results":
fmt.Fprint(w, `{"errors":false,"items":[]}`)
}
}))
defer upstream.Close()
backend := NewOpenSearchMediaBackend(config.SearchConfig{Backend: "opensearch", OpenSearchURL: upstream.URL})
if err := backend.IndexMedia(t.Context(), []model.Media{{Base: model.Base{ID: "m1"}}}); err == nil {
t.Fatal("failed indexing was reported as successful")
}
want := 1
if strings.HasPrefix(scenario, "transient") {
want = 3
}
if calls != want {
t.Fatalf("calls = %d, want %d", calls, want)
}
})
}
}
func TestOpenSearchBulkHonorsCancellationDuringRetry(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
calls := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
writeBulkResults(w, bulkRequestIDs(r), map[string]int{"m1": http.StatusTooManyRequests})
cancel()
}))
defer upstream.Close()
backend := NewOpenSearchMediaBackend(config.SearchConfig{Backend: "opensearch", OpenSearchURL: upstream.URL})
err := backend.IndexMedia(ctx, []model.Media{{Base: model.Base{ID: "m1"}}})
if !errors.Is(err, context.Canceled) || calls != 1 {
t.Fatalf("error = %v, calls = %d", err, calls)
}
}
func TestMediaUpsertBatchIndexesCommittedRowsTogether(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1)
defer sqlDB.Close()
if err := db.AutoMigrate(&model.Media{}); err != nil {
t.Fatal(err)
}
repos := New(db)
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
calls := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
ids := bulkRequestIDs(r)
if len(ids) != 5 {
t.Errorf("bulk contains %d documents, want 5", len(ids))
}
// With only one DB connection this also detects network work inside the
// transaction: a separate read could not observe the committed batch.
var count int64
if err := db.WithContext(ctx).Model(&model.Media{}).Count(&count).Error; err != nil || count != 5 {
t.Errorf("committed rows = %d, error = %v", count, err)
}
writeBulkResults(w, ids, nil)
}))
defer upstream.Close()
repos.Media.SetSearchBackend(NewOpenSearchMediaBackend(config.SearchConfig{Backend: "opensearch", OpenSearchURL: upstream.URL}))
var rows []*model.Media
for i := 0; i < 5; i++ {
rows = append(rows, &model.Media{LibraryID: "lib", Path: fmt.Sprintf("/media/%d.mkv", i)})
}
if err := repos.Media.UpsertBatch(ctx, rows); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatalf("index requests = %d, want one bulk", calls)
}
}
@@ -24,6 +24,14 @@ func (s *ApiConfigService) TestConnection(ctx context.Context, provider string)
switch provider {
case "tmdb":
return s.testTMDb(cfg)
case "bangumi":
return s.testBangumi(ctx, cfg)
case "thetvdb":
return s.testTheTVDB(ctx, cfg)
case "fanart":
return s.testFanart(ctx, cfg)
case "douban":
return s.testDouban(ctx, cfg)
case "openai":
return s.testOpenAI(cfg)
case "deepseek":
@@ -0,0 +1,191 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/truewhile/MeBox/internal/model"
)
// Bangumi's public metadata endpoints work without a token. If one was saved,
// also check /v0/me: public endpoints alone may accept an expired token.
func (s *ApiConfigService) testBangumi(ctx context.Context, cfg *model.APIConfig) (string, error) {
req, err := newMetadataProbeRequest(ctx, cfg, "https://api.bgm.tv", "/v0/subjects/253", http.MethodGet, nil)
if err != nil {
return "error", err
}
if token := strings.TrimSpace(cfg.APIKey); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
identityReq := req.Clone(ctx)
identityURL := *req.URL
identityURL.Path = strings.TrimSuffix(identityURL.Path, "/subjects/253") + "/me"
identityReq.URL = &identityURL
var identity struct {
ID int `json:"id"`
}
if result, err := testMetadataProbeJSON(identityReq, "Bangumi", &identity); err != nil {
return result, err
}
if identity.ID <= 0 {
return "error", errors.New("Bangumi returned an invalid user response")
}
}
var subject struct {
ID int `json:"id"`
Name string `json:"name"`
}
if result, err := testMetadataProbeJSON(req, "Bangumi", &subject); err != nil {
return result, err
}
if subject.ID != 253 || strings.TrimSpace(subject.Name) == "" {
return "error", errors.New("Bangumi returned an invalid subject response")
}
return "success", nil
}
func (s *ApiConfigService) testTheTVDB(ctx context.Context, cfg *model.APIConfig) (string, error) {
key := strings.TrimSpace(cfg.APIKey)
if key == "" {
return "error", errors.New("TheTVDB API key is required")
}
body, err := json.Marshal(map[string]string{"apikey": key})
if err != nil {
return "error", err
}
req, err := newMetadataProbeRequest(ctx, cfg, "https://api4.thetvdb.com/v4", "/login", http.MethodPost, strings.NewReader(string(body)))
if err != nil {
return "error", err
}
req.Header.Set("Content-Type", "application/json")
var response struct {
Status string `json:"status"`
Data struct {
Token string `json:"token"`
} `json:"data"`
}
if result, err := testMetadataProbeJSON(req, "TheTVDB", &response); err != nil {
return result, err
}
if response.Status != "success" || strings.TrimSpace(response.Data.Token) == "" {
return "error", errors.New("TheTVDB login did not return a valid token")
}
return "success", nil
}
func (s *ApiConfigService) testFanart(ctx context.Context, cfg *model.APIConfig) (string, error) {
key := strings.TrimSpace(cfg.APIKey)
if key == "" {
return "error", errors.New("Fanart API key is required")
}
req, err := newMetadataProbeRequest(ctx, cfg, "https://webservice.fanart.tv/v3", "/movies/550", http.MethodGet, nil)
if err != nil {
return "error", err
}
query := req.URL.Query()
query.Set("api_key", key)
req.URL.RawQuery = query.Encode()
var response struct {
TMDbID json.Number `json:"tmdb_id"`
Name string `json:"name"`
}
if result, err := testMetadataProbeJSON(req, "Fanart", &response); err != nil {
return result, err
}
if response.TMDbID.String() != "550" || strings.TrimSpace(response.Name) == "" {
return "error", errors.New("Fanart returned an invalid movie response")
}
return "success", nil
}
func (s *ApiConfigService) testDouban(ctx context.Context, cfg *model.APIConfig) (string, error) {
req, err := newMetadataProbeRequest(ctx, cfg, "https://movie.douban.com", "/j/subject_suggest", http.MethodGet, nil)
if err != nil {
return "error", err
}
query := req.URL.Query()
query.Set("q", "Inception")
req.URL.RawQuery = query.Encode()
req.Header.Set("User-Agent", userAgents[0])
req.Header.Set("Referer", "https://movie.douban.com/")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
if cookie := strings.TrimSpace(cfg.APIKey); cookie != "" {
req.Header.Set("Cookie", cookie)
}
var suggestions []struct {
ID string `json:"id"`
Title string `json:"title"`
}
if result, err := testMetadataProbeJSON(req, "Douban", &suggestions); err != nil {
return result, err
}
// An empty array is a valid search result; null or an error object is not.
if suggestions == nil {
return "error", errors.New("Douban returned an invalid search response (login or verification may be required)")
}
for _, suggestion := range suggestions {
if suggestion.ID == "" || strings.TrimSpace(suggestion.Title) == "" {
return "error", errors.New("Douban returned an invalid search suggestion")
}
}
return "success", nil
}
func newMetadataProbeRequest(ctx context.Context, cfg *model.APIConfig, defaultBase, path, method string, body io.Reader) (*http.Request, error) {
base := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
if base == "" {
base = defaultBase
}
req, err := http.NewRequestWithContext(ctx, method, base+path, body)
if err != nil {
// The saved base URL may contain credentials. Never echo it to the UI.
return nil, fmt.Errorf("%s: invalid base URL", cfg.Provider)
}
req.Header.Set("User-Agent", "MeBox/0.1 (https://github.com/truewhile/MeBox)")
req.Header.Set("Accept", "application/json")
return req, nil
}
// Decode a bounded, complete JSON document rather than accepting any HTTP 200
// (reverse proxies and anti-bot/login pages often return 200 as well).
func testMetadataProbeJSON(req *http.Request, provider string, out any) (string, error) {
client := NewExternalHTTPClient(10 * time.Second)
defer client.CloseIdleConnections()
resp, err := client.Do(req)
if err != nil {
// url.Error includes the full request URL, including Fanart's API key.
var urlErr *url.Error
if errors.As(err, &urlErr) {
err = urlErr.Err
}
return "error", fmt.Errorf("%s connection failed: %w", provider, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return "invalid", fmt.Errorf("%s authentication failed (HTTP 401): check configured credentials", provider)
}
if resp.StatusCode == http.StatusForbidden {
return "error", fmt.Errorf("%s access denied (HTTP 403): check credentials, permissions or anti-bot verification", provider)
}
if resp.StatusCode != http.StatusOK {
return "error", fmt.Errorf("%s API returned status %d", provider, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1))
if err != nil {
return "error", fmt.Errorf("%s response read failed: %w", provider, err)
}
if len(body) > 1<<20 {
return "error", fmt.Errorf("%s response exceeds 1 MiB", provider)
}
if err := json.Unmarshal(body, out); err != nil {
// Do not include the response body: it may echo credentials.
return "error", fmt.Errorf("%s returned invalid JSON (login or verification page may have intercepted the request)", provider)
}
return "success", nil
}
@@ -0,0 +1,252 @@
package service
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/repository"
)
type metadataProbeFixture struct {
provider string
path string
key string
body string
}
var metadataProbeFixtures = []metadataProbeFixture{
{"bangumi", "/v0/subjects/253", "", `{"id":253,"name":"カウボーイビバップ","type":2}`},
{"thetvdb", "/login", "tvdb-test-secret", `{"status":"success","data":{"token":"test-jwt"}}`},
{"fanart", "/movies/550", "fanart+test&secret", `{"name":"Fight Club","tmdb_id":"550","imdb_id":"tt0137523","movieposter":[]}`},
{"douban", "/j/subject_suggest", "bid=test-cookie", `[{"id":"3541415","title":"盗梦空间","year":"2010","type":"movie"}]`},
}
func newMetadataConnectionTestService(t *testing.T) *ApiConfigService {
t.Helper()
log := zap.NewNop()
return NewApiConfigService(&config.Config{}, log, repository.New(newServiceTestDB(t)), NewCryptoService("metadata-test-secret", log))
}
func saveMetadataProbeConfig(t *testing.T, svc *ApiConfigService, provider, baseURL, key string) {
t.Helper()
if _, err := svc.Upsert(t.Context(), provider, key, baseURL, "", true); err != nil {
t.Fatal(err)
}
}
// Removing a dispatch branch, ignoring the saved URL/key, or using the wrong
// endpoint must fail this integration test (real database and HTTP server).
func TestMetadataConnectionUsesSavedConfig(t *testing.T) {
svc := newMetadataConnectionTestService(t)
for _, fixture := range metadataProbeFixtures {
t.Run(fixture.provider, func(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/proxy"+fixture.path {
t.Errorf("request path = %q", r.URL.Path)
}
if r.Header.Get("User-Agent") == "" {
t.Error("missing User-Agent")
}
if fixture.provider == "thetvdb" {
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Error(err)
}
if r.Method != http.MethodPost || body["apikey"] != fixture.key || r.Header.Get("Content-Type") != "application/json" {
t.Error("TheTVDB login must POST the saved, decrypted API key as JSON")
}
} else if r.Method != http.MethodGet {
t.Errorf("method = %s, want GET", r.Method)
}
if fixture.provider == "fanart" && r.URL.Query().Get("api_key") != fixture.key {
t.Error("Fanart API key was not escaped/passed correctly")
}
if fixture.provider == "douban" {
if r.Header.Get("Cookie") != fixture.key || r.Header.Get("Referer") != "https://movie.douban.com/" || r.URL.Query().Get("q") == "" {
t.Error("Douban search must carry the configured cookie, referer and query")
}
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(fixture.body))
}))
defer server.Close()
saveMetadataProbeConfig(t, svc, fixture.provider, " "+server.URL+"/proxy/ ", fixture.key)
result, err := svc.TestConnection(t.Context(), fixture.provider)
if err != nil || result != "success" {
t.Fatalf("TestConnection = %q, %v, want success", result, err)
}
if calls != 1 {
t.Errorf("HTTP calls = %d, want 1", calls)
}
})
}
}
func TestMetadataConnectionRejectsBadResponses(t *testing.T) {
svc := newMetadataConnectionTestService(t)
for _, fixture := range metadataProbeFixtures {
for _, response := range []struct {
name string
status int
body string
result string
}{
{"unauthorized", 401, `{"error":"denied"}`, "invalid"},
{"forbidden", 403, `<html>Access denied</html>`, "error"},
{"rate limited", 429, `{}`, "error"},
{"upstream error", 503, `{}`, "error"},
{"challenge page", 200, `<html>Please log in or complete verification</html>`, "error"},
{"empty body", 200, ``, "error"},
{"wrong schema", 200, `{"error":"intercepted"}`, "error"},
{"null", 200, `null`, "error"},
{"trailing garbage", 200, fixture.body + `<html>blocked</html>`, "error"},
} {
t.Run(fixture.provider+"/"+response.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(response.status)
_, _ = w.Write([]byte(response.body))
}))
defer server.Close()
saveMetadataProbeConfig(t, svc, fixture.provider, server.URL, fixture.key)
result, err := svc.TestConnection(t.Context(), fixture.provider)
if err == nil || result != response.result {
t.Fatalf("TestConnection = %q, %v, want %q and error", result, err, response.result)
}
if fixture.key != "" && strings.Contains(err.Error(), fixture.key) {
t.Error("error exposes configured credentials")
}
})
}
}
}
func TestMetadataConnectionRequiresAPIKeys(t *testing.T) {
svc := newMetadataConnectionTestService(t)
for _, provider := range []string{"thetvdb", "fanart"} {
t.Run(provider, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("must reject missing key before making a request")
}))
defer server.Close()
saveMetadataProbeConfig(t, svc, provider, server.URL, " ")
result, err := svc.TestConnection(t.Context(), provider)
if result != "error" || err == nil || !strings.Contains(err.Error(), "API key") {
t.Fatalf("TestConnection = %q, %v, want missing API key error", result, err)
}
})
}
}
func TestMetadataConnectionBangumiValidatesConfiguredToken(t *testing.T) {
svc := newMetadataConnectionTestService(t)
for _, valid := range []bool{true, false} {
name := "valid"
if !valid {
name = "invalid"
}
t.Run(name, func(t *testing.T) {
meCalls, subjectCalls := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer bangumi-test-token" {
t.Error("missing saved Bangumi bearer token")
}
switch r.URL.Path {
case "/v0/me":
meCalls++
if !valid {
w.WriteHeader(http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte(`{"id":42,"username":"test-user"}`))
case "/v0/subjects/253":
subjectCalls++
_, _ = w.Write([]byte(`{"id":253,"name":"カウボーイビバップ"}`))
default:
t.Errorf("unexpected path: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
saveMetadataProbeConfig(t, svc, "bangumi", server.URL, "bangumi-test-token")
result, err := svc.TestConnection(t.Context(), "bangumi")
if valid && (result != "success" || err != nil || subjectCalls != 1) {
t.Fatalf("valid token: result=%q err=%v subjectCalls=%d", result, err, subjectCalls)
}
if !valid && (result != "invalid" || err == nil || subjectCalls != 0) {
t.Fatalf("invalid token: result=%q err=%v subjectCalls=%d", result, err, subjectCalls)
}
if meCalls != 1 {
t.Errorf("identity requests = %d, want 1", meCalls)
}
})
}
}
func TestMetadataConnectionDoubanDoesNotRequireCookie(t *testing.T) {
svc := newMetadataConnectionTestService(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Cookie") != "" {
t.Error("unexpected Cookie for public search")
}
_, _ = w.Write([]byte(`[]`))
}))
defer server.Close()
saveMetadataProbeConfig(t, svc, "douban", server.URL, "")
result, err := svc.TestConnection(t.Context(), "douban")
if result != "success" || err != nil {
t.Fatalf("public search with empty results = %q, %v", result, err)
}
}
func TestMetadataConnectionHonorsContext(t *testing.T) {
svc := newMetadataConnectionTestService(t)
for _, fixture := range metadataProbeFixtures {
t.Run(fixture.provider, func(t *testing.T) {
started := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
close(started)
<-r.Context().Done()
}))
defer server.Close()
saveMetadataProbeConfig(t, svc, fixture.provider, server.URL, fixture.key)
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
defer cancel()
go func() {
select {
case <-started:
cancel()
case <-ctx.Done():
}
}()
result, err := svc.TestConnection(ctx, fixture.provider)
if result != "error" || !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled request = %q, %v, want context.Canceled", result, err)
}
})
}
}
func TestMetadataConnectionTransportErrorsHideKeys(t *testing.T) {
svc := newMetadataConnectionTestService(t)
server := httptest.NewServer(http.NotFoundHandler())
server.Close()
key := "sensitive-fanart-key"
saveMetadataProbeConfig(t, svc, "fanart", server.URL, key)
result, err := svc.TestConnection(t.Context(), "fanart")
if result != "error" || err == nil || strings.Contains(err.Error(), key) {
t.Fatalf("transport failure = %q, %v, want error without credentials", result, err)
}
}
+81 -37
View File
@@ -5,7 +5,7 @@
//
// 两种方言的备份形态不同:
// - SQLite:用 "VACUUM INTO"(3.27.0 起可用)产出一个一致的库文件,
// 扩展名 .db;恢复即「替换库文件 + 重启」。
// 扩展名 .db;恢复先暂存,在下次启动打开数据库前替换文件。
// - PostgreSQL:没有单文件语义,用逻辑备份(见 backup_postgres.go),
// 扩展名 .json.gz;恢复是在单个事务内重建全部表。
package service
@@ -24,6 +24,7 @@ import (
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/database"
)
// BackupService manages database backups.
@@ -44,6 +45,7 @@ type BackupInfo struct {
Path string `json:"path"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"created_at"`
Automatic bool `json:"automatic"`
}
func (b *BackupService) backupDir() string {
@@ -69,6 +71,23 @@ func (b *BackupService) backupFilePath(filename string) (string, error) {
return path, nil
}
// ResolvePath returns the absolute path of a listed backup after applying the
// same filename and directory checks used by restore and delete operations.
func (b *BackupService) ResolvePath(filename string) (string, error) {
path, err := b.backupFilePath(filename)
if err != nil {
return "", err
}
info, err := os.Lstat(path)
if err != nil {
return "", err
}
if !info.Mode().IsRegular() {
return "", errors.New("backup is not a regular file")
}
return path, nil
}
// isPostgres reports whether the service is backed by PostgreSQL.
//
// PostgreSQL 不支持 SQLite 的 "VACUUM INTO",按文件替换恢复也会写错目标,
@@ -80,11 +99,23 @@ func (b *BackupService) isPostgres() bool {
// Create produces a new hot backup. SQLite uses "VACUUM INTO"; PostgreSQL uses
// a logical dump.
func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
return b.create(ctx, false)
}
func (b *BackupService) CreateAutomatic(ctx context.Context) (*BackupInfo, error) {
return b.create(ctx, true)
}
func (b *BackupService) create(ctx context.Context, automatic bool) (*BackupInfo, error) {
dir := b.backupDir()
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, err
}
ts := time.Now().Format("20060102_150405")
now := time.Now()
ts := fmt.Sprintf("%s_%09d", now.Format("20060102_150405"), now.Nanosecond())
if automatic {
ts = "auto_" + ts
}
var (
dst string
@@ -108,15 +139,24 @@ func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
Path: dst,
Size: stat.Size(),
CreatedAt: stat.ModTime(),
Automatic: automatic,
}, nil
}
func (b *BackupService) createSQLiteBackup(ctx context.Context, stamp string) (string, error) {
dst := filepath.Join(b.backupDir(), fmt.Sprintf("mebox_%s.db", stamp))
// VACUUM INTO creates a clean, non-WAL copy atomically.
if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", dst).Error; err != nil {
tmp := dst + ".tmp"
defer os.Remove(tmp)
// Publish only completed backups, with credentials protected by file mode.
if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", tmp).Error; err != nil {
return "", fmt.Errorf("VACUUM INTO failed: %w", err)
}
if err := os.Chmod(tmp, 0o600); err != nil {
return "", err
}
if err := os.Rename(tmp, dst); err != nil {
return "", err
}
return dst, nil
}
@@ -132,7 +172,7 @@ func (b *BackupService) List() ([]BackupInfo, error) {
}
out := make([]BackupInfo, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !isBackupFilename(e.Name()) {
if !e.Type().IsRegular() || !isBackupFilename(e.Name()) {
continue
}
info, err := e.Info()
@@ -144,6 +184,7 @@ func (b *BackupService) List() ([]BackupInfo, error) {
Path: filepath.Join(dir, e.Name()),
Size: info.Size(),
CreatedAt: info.ModTime(),
Automatic: strings.HasPrefix(e.Name(), "mebox_auto_"),
})
}
sort.Slice(out, func(i, j int) bool {
@@ -152,6 +193,36 @@ func (b *BackupService) List() ([]BackupInfo, error) {
return out, nil
}
// Prune keeps maxCount automatic backups for the current database dialect.
// Manual backups and backups from another dialect are never pruned.
// A value below one disables pruning so an invalid setting cannot erase all
// recoverable copies.
func (b *BackupService) Prune(maxCount int) error {
if maxCount < 1 {
return nil
}
items, err := b.List()
if err != nil {
return err
}
eligible := items[:0]
for _, item := range items {
if item.Automatic && strings.HasSuffix(item.Filename, postgresBackupSuffix) == b.isPostgres() {
eligible = append(eligible, item)
}
}
items = eligible
if maxCount >= len(items) {
return nil
}
for _, item := range items[maxCount:] {
if err := b.Delete(item.Filename); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
return nil
}
// Delete removes a single backup file.
func (b *BackupService) Delete(filename string) error {
path, err := b.backupFilePath(filename)
@@ -161,12 +232,10 @@ func (b *BackupService) Delete(filename string) error {
return os.Remove(path)
}
// Restore replaces the live database with the given backup.
// WARNING: this is destructive. SQLite restores by swapping the database file
// (the caller should shut down the server afterwards); PostgreSQL restores by
// rebuilding every table inside a single transaction.
// Restore stages SQLite backups for the next startup. PostgreSQL restores
// rebuild every table inside a single transaction.
func (b *BackupService) Restore(ctx context.Context, filename string) error {
src, err := b.backupFilePath(filename)
src, err := b.ResolvePath(filename)
if err != nil {
return err
}
@@ -182,35 +251,10 @@ func (b *BackupService) Restore(ctx context.Context, filename string) error {
if !strings.HasSuffix(filename, ".db") {
return errors.New("该备份是 PostgreSQL 逻辑备份,当前运行在 SQLite 上,无法恢复")
}
return b.restoreSQLiteBackup(filename, src)
}
func (b *BackupService) restoreSQLiteBackup(filename, src string) error {
dbPath, err := filepath.Abs(b.cfg.Database.DBPath)
if err != nil || dbPath == "" {
return errors.New("invalid database path")
}
// Strategy: rename live → live.old, copy backup → live, delete old.
old := dbPath + ".before_restore"
if err := os.Rename(dbPath, old); err != nil && !errors.Is(err, os.ErrNotExist) {
if err := database.StageSQLiteRestore(ctx, b.cfg, src); err != nil {
return err
}
data, err := os.ReadFile(src) // #nosec G304 -- src is constrained by backupFilePath to the configured backups directory.
if err != nil {
// Revert
_ = os.Rename(old, dbPath)
return err
}
if err := os.WriteFile(dbPath, data, 0o600); err != nil { // #nosec G703 -- database path is trusted process config and normalized before restore.
_ = os.Rename(old, dbPath)
return err
}
_ = os.Remove(old)
// Also remove WAL/SHM so SQLite opens the fresh file cleanly.
_ = os.Remove(dbPath + "-wal")
_ = os.Remove(dbPath + "-shm")
b.log.Warn("database restored from backup — restart the server",
zap.String("backup", filename))
b.log.Warn("SQLite backup staged; restart to apply", zap.String("backup", filename))
return nil
}
+33 -12
View File
@@ -3,6 +3,7 @@ package service
import (
"compress/gzip"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
@@ -87,7 +88,8 @@ func postgresBackupModelSchemas(db *gorm.DB) (map[string]*schema.Schema, map[str
// createPostgresBackup 把全库导出成一个 gzip 压缩的 JSON Lines 文件。
func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string) (string, error) {
dst := filepath.Join(b.backupDir(), "mebox_"+stamp+postgresBackupSuffix)
file, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
tmp := dst + ".tmp"
file, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return "", err
}
@@ -96,7 +98,7 @@ func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string)
_ = file.Close()
if !complete {
// 半截备份文件比没有备份更危险:留着会让人以为有可用的还原点。
_ = os.Remove(dst)
_ = os.Remove(tmp)
}
}()
@@ -109,17 +111,30 @@ func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string)
}); err != nil {
return "", fmt.Errorf("写入备份头失败: %w", err)
}
for _, m := range model.AllModels() {
table, sch, err := parseModelSchema(b.db, m)
if err != nil {
return "", err
}
if !b.db.Migrator().HasTable(table) {
continue
}
if err := b.dumpPostgresTable(ctx, m, sch, table, enc); err != nil {
return "", err
var options []*sql.TxOptions
if b.isPostgres() {
options = append(options, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
}
// Every table must come from the same snapshot, including while background
// workers are inserting related rows during an automatic backup.
if err := b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
snapshot := *b
snapshot.db = tx
for _, m := range model.AllModels() {
table, sch, err := parseModelSchema(tx, m)
if err != nil {
return err
}
if !tx.Migrator().HasTable(table) {
continue
}
if err := snapshot.dumpPostgresTable(ctx, m, sch, table, enc); err != nil {
return err
}
}
return nil
}, options...); err != nil {
return "", err
}
if err := zw.Close(); err != nil {
return "", fmt.Errorf("压缩备份失败: %w", err)
@@ -127,6 +142,12 @@ func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string)
if err := file.Sync(); err != nil {
return "", fmt.Errorf("写入备份失败: %w", err)
}
if err := file.Close(); err != nil {
return "", err
}
if err := os.Rename(tmp, dst); err != nil {
return "", err
}
complete = true
return dst, nil
}
+65
View File
@@ -0,0 +1,65 @@
package service
import (
"os"
"path/filepath"
"runtime"
"testing"
"time"
)
func TestBackupRetentionPreservesManualAndOtherDialectBackups(t *testing.T) {
svc, _ := newBackupTestService(t)
names := []string{"mebox_auto_new.db", "mebox_auto_old.db", "mebox_manual.db", "mebox_auto_postgres.json.gz"}
for i, name := range names {
path := filepath.Join(svc.backupDir(), name)
if err := os.WriteFile(path, []byte("fixture"), 0o600); err != nil {
t.Fatal(err)
}
when := time.Now().Add(-time.Duration(i) * time.Hour)
if err := os.Chtimes(path, when, when); err != nil {
t.Fatal(err)
}
}
if err := svc.Prune(1); err != nil {
t.Fatal(err)
}
for _, name := range names {
_, err := os.Stat(filepath.Join(svc.backupDir(), name))
if name == "mebox_auto_old.db" {
if !os.IsNotExist(err) {
t.Fatalf("old automatic backup was retained: %v", err)
}
} else if err != nil {
t.Fatalf("backup %s should have been preserved: %v", name, err)
}
}
}
func TestBackupCreateUsesUniquePrivateCompletedFiles(t *testing.T) {
svc, _ := newBackupTestService(t)
manual, err := svc.Create(t.Context())
if err != nil {
t.Fatal(err)
}
automatic, err := svc.CreateAutomatic(t.Context())
if err != nil {
t.Fatal(err)
}
if manual.Filename == automatic.Filename || manual.Automatic || !automatic.Automatic {
t.Fatalf("invalid backup identities: manual=%#v automatic=%#v", manual, automatic)
}
items, err := svc.List()
if err != nil || len(items) != 2 {
t.Fatalf("completed backup list: items=%#v err=%v", items, err)
}
for _, item := range items {
info, err := os.Stat(item.Path)
if err != nil {
t.Fatal(err)
}
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
t.Fatalf("backup permissions are not private: %s err=%v", item.Path, err)
}
}
}
+218 -34
View File
@@ -74,22 +74,20 @@ type ImageCacheScope struct {
DropUnknownResizeVariants bool
}
// ImageCachePool 描述一个图片缓存池:范围 + 自身配额 + 保留时长。
// ImageCachePool 描述一个图片缓存池:范围 + 自身配额。
//
// 分池的意义在于两类缓存的“可再生成本”完全不同:原图只是生成缩略图的原料,
// 丢了可以重新回源;派生缩略图是客户端热路径真正读取的成品,重新生成代价高。
// 因此原图配小配额、短保留,总量超限时也优先淘汰原图。
// 因此原图配小配额,总量超限时也优先淘汰原图。
//
// 淘汰只看占用(配额头寸),不按文件年龄删除:缩略图生成成本高,只要磁盘还
// 放得下就该留着,让再次浏览直接命中缓存。
type ImageCachePool struct {
Name string
Scopes []ImageCacheScope
MaxBytes int64 // 0 = 不单独限制
MaxAge time.Duration // 0 = 不按时间淘汰
MaxBytes int64 // 0 = 不单独限制
}
// imageFailMarkerMaxAge 是失败标记文件的保留时长:标记只用于观测/重试,
// 过期即视为噪音清掉(不会因为标记残留而阻止重新抓取)。
const imageFailMarkerMaxAge = 24 * time.Hour
// tempImageCacheFile 报告文件名是否是下载/缩放过程中的临时文件。它们正在被
// 并发写入,清理时必须跳过。
func tempImageCacheFile(name string) bool {
@@ -182,12 +180,14 @@ func removeImageCacheEntry(entry imageCacheFileEntry, result *PruneImageCacheRes
_ = os.Remove(entry.path + ".fail")
}
// PruneImageCachePools 按池清理图片缓存:
// 1. 删除超过池 MaxAge 的文件(原图按保留时长淘汰);
// 2. 池自身超过 MaxBytes 时按 mtime 淘汰到 80%(留水位,避免连续写入即触发);
// 3. 全部池合计超过 totalBytes 时,仍按“先原图、后派生”的顺序淘汰最旧文件,
// PruneImageCachePools 按池清理图片缓存,只做占用淘汰:
// 1. 池自身超过 MaxBytes 时按 mtime 淘汰到 80%(留水位,避免连续写入即触发);
// 2. 全部池合计超过 totalBytes 时,仍按“先原图、后派生”的顺序淘汰最旧文件,
// 使总量上限始终是硬保证,同时让客户端热路径的缩略图活得更久。
//
// 不按文件年龄删除:过期即删会让一张几天前看过的海报重新解码(2 核机器上
// 每张约 0.2 秒),代价远高于占着那几十 KB 磁盘。
//
// 传入的池顺序即总量超限时的淘汰优先级(排在前面的先被淘汰)。
func PruneImageCachePools(pools []ImageCachePool, totalBytes int64) (PruneImageCacheResult, error) {
var result PruneImageCacheResult
@@ -207,16 +207,18 @@ func PruneImageCachePools(pools []ImageCachePool, totalBytes int64) (PruneImageC
}
result.RemainingBytes = result.TotalBytesBefore
// 过期的失败标记直接清掉:它们不计容量,只用于观测与重试。
cutoffMarkers := time.Now().Add(-imageFailMarkerMaxAge)
// 失败标记不计入容量:只清掉对应缓存文件已经不存在的孤儿标记(它们只是
// 观测/重试用的噪音,留着没有任何作用)。
for _, marker := range failMarkers {
if marker.modTime.Before(cutoffMarkers) {
_ = os.Remove(marker.path)
base := strings.TrimSuffix(marker.path, ".fail")
if _, err := os.Stat(base); err == nil {
continue
}
_ = os.Remove(marker.path)
}
now := time.Now()
deleted := make(map[string]bool, len(entries))
deleteEntry := func(entry imageCacheFileEntry) {
if deleted[entry.path] {
return
@@ -225,20 +227,7 @@ func PruneImageCachePools(pools []ImageCachePool, totalBytes int64) (PruneImageC
removeImageCacheEntry(entry, &result)
}
// 1. 按池保留时长淘汰(原图池)。
for i := range pools {
if pools[i].MaxAge <= 0 {
continue
}
cutoff := now.Add(-pools[i].MaxAge)
for _, entry := range entries {
if entry.pool == i && entry.modTime.Before(cutoff) {
deleteEntry(entry)
}
}
}
// 2. 池自身配额。
// 1. 池自身配额。
for i := range pools {
maxBytes := pools[i].MaxBytes
if maxBytes <= 0 {
@@ -263,7 +252,7 @@ func PruneImageCachePools(pools []ImageCachePool, totalBytes int64) (PruneImageC
}
}
// 3. 总量硬上限:跨池按淘汰优先级(池顺序)再按 mtime 淘汰。
// 2. 总量硬上限:跨池按淘汰优先级(池顺序)再按 mtime 淘汰。
if totalBytes > 0 && result.RemainingBytes > totalBytes {
target := totalBytes * 80 / 100
for _, entry := range evictionOrder(entries, deleted) {
@@ -357,11 +346,10 @@ func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResul
// 第一个池是原图:历史版本平铺在 images/ 下的旧缓存也归入此池,升级后会被
// 逐步淘汰;第二个池是派生成品(本地缩放结果 + 挂载 Emby 按尺寸返回的成品)。
// 池顺序即总量超限时的淘汰优先级。
func ImageCachePools(imagesDir string, originalsMaxBytes int64, originalsMaxAge time.Duration) []ImageCachePool {
func ImageCachePools(imagesDir string, originalsMaxBytes int64) []ImageCachePool {
originals := ImageCachePool{
Name: "originals",
MaxBytes: originalsMaxBytes,
MaxAge: originalsMaxAge,
Scopes: []ImageCacheScope{
{Root: imagesDir, Recursive: false}, // 旧版平铺布局
{Root: filepath.Join(imagesDir, imageOriginalCacheSubdir), Recursive: true},
@@ -376,3 +364,199 @@ func ImageCachePools(imagesDir string, originalsMaxBytes int64, originalsMaxAge
}
return []ImageCachePool{originals, derived}
}
// CacheCleanupResult 是全缓存容量淘汰的统计。
type CacheCleanupResult struct {
TotalFilesBefore int
TotalBytesBefore int64
DeletedFiles int
FreedBytes int64
RemainingBytes int64
// FreedByPool 记录每个池释放的字节数,方便日志定位到底是哪类缓存占满了。
FreedByPool map[string]int64
}
// cacheEvictionEntry 是全局容量淘汰里的一个候选文件。
type cacheEvictionEntry struct {
path string
pool string
size int64
modTime time.Time
}
// cacheEvictionPoolOrder 是全局容量清理的固定优先级。
// 顺序即“先清谁”:超限时前面的池会被整池清空,才会轮到后面的池。
// reader-audio 不在其中:它由自身固定配额(当前 1GiB,见 reader 包的
// audioMediaMaxBytes)在写入时独立淘汰。
var cacheEvictionPoolOrder = []string{
"hls",
"images",
"library-covers",
}
// cacheCleanupKeepPercent 是触发清理后回落到的水位(占上限的百分比)。
// 留出余量,避免刚清完又被下一次写入立刻顶到上限。
const cacheCleanupKeepPercent = 80
// tempCacheFile 判断文件名是否是缓存写入中的临时产物。这些文件正在被并发
// 写入,容量统计和清理都应跳过。
func tempCacheFile(name string) bool {
if tempImageCacheFile(name) {
return true
}
if strings.HasSuffix(name, ".part") {
return true
}
// 各缓存目录的原子写入临时文件前缀:.content- / .cover- / .audio- / .js-cache-。
return strings.HasPrefix(name, ".content-") ||
strings.HasPrefix(name, ".cover-") ||
strings.HasPrefix(name, ".audio-") ||
strings.HasPrefix(name, ".js-cache-")
}
// collectCachePoolFiles 递归收集一个普通缓存池里的候选文件,按 mtime 从旧到新。
func collectCachePoolFiles(root, pool string) []cacheEvictionEntry {
if strings.TrimSpace(root) == "" {
return nil
}
var out []cacheEvictionEntry
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info == nil || info.IsDir() {
return nil
}
if !info.Mode().IsRegular() {
return nil
}
name := info.Name()
if tempCacheFile(name) || strings.HasSuffix(name, ".fail") {
return nil
}
out = append(out, cacheEvictionEntry{
path: path,
pool: pool,
size: info.Size(),
modTime: info.ModTime(),
})
return nil
})
sort.Slice(out, func(i, j int) bool { return out[i].modTime.Before(out[j].modTime) })
return out
}
// collectImageCacheEntries 收集图片缓存候选文件。返回顺序沿用图片池的优先级:
// 先原图、后派生成品,池内按 mtime 从旧到新。
func collectImageCacheEntries(imagesDir string) []cacheEvictionEntry {
if strings.TrimSpace(imagesDir) == "" {
return nil
}
if _, err := os.Stat(imagesDir); err != nil {
return nil
}
pools := ImageCachePools(imagesDir, 0)
var entries []imageCacheFileEntry
var failMarkers []imageCacheFileEntry
for i, pool := range pools {
collectImageCachePoolFiles(pool, i, &entries, &failMarkers)
}
ordered := evictionOrder(entries, map[string]bool{})
out := make([]cacheEvictionEntry, 0, len(ordered))
for _, e := range ordered {
out = append(out, cacheEvictionEntry{
path: e.path,
pool: "images",
size: e.size,
modTime: e.modTime,
})
}
return out
}
// removeEmptyDirsUnder 删除 root 下淘汰后留下的空子目录(不含 root 本身)。
func removeEmptyDirsUnder(root string) {
if strings.TrimSpace(root) == "" {
return
}
if _, err := os.Stat(root); err != nil {
return
}
dirs := []string{}
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info == nil {
return nil
}
if info.IsDir() && path != root {
dirs = append(dirs, path)
}
return nil
})
for i := len(dirs) - 1; i >= 0; i-- {
_ = os.Remove(dirs[i])
}
}
// PruneCache 按全局容量池清理磁盘缓存。
//
// 总量口径是 hls、images、library-covers 三个池之和;reader-audio 及正文缓存、
// 书源文件缓存都不参与这里的总量计算——reader-audio 由自身固定配额(当前
// 1GiB)在写入时淘汰,正文缓存和书源文件缓存另有自己的 TTL / 配额。
// 超过 totalBytes 时严格按 hls → images → library-covers 的顺序淘汰最旧文件,
// 直到回落到上限的 80%。
// originalsMaxBytes 是 images 原图池的独立子配额,在总量淘汰之后单独执行
// (即使总量没超也会把原图压回配额)。
func PruneCache(cacheDir string, totalBytes, originalsMaxBytes int64) (CacheCleanupResult, error) {
result := CacheCleanupResult{FreedByPool: map[string]int64{}}
if strings.TrimSpace(cacheDir) == "" {
return result, nil
}
imagesDir := filepath.Join(cacheDir, "images")
ordered := make([]cacheEvictionEntry, 0, 1024)
for _, name := range cacheEvictionPoolOrder {
if name == "images" {
ordered = append(ordered, collectImageCacheEntries(imagesDir)...)
continue
}
ordered = append(ordered, collectCachePoolFiles(filepath.Join(cacheDir, name), name)...)
}
result.TotalFilesBefore = len(ordered)
for _, entry := range ordered {
result.TotalBytesBefore += entry.size
}
result.RemainingBytes = result.TotalBytesBefore
// 1. 全局总量:严格按池优先级淘汰,直到回落到水位。
if totalBytes > 0 && result.RemainingBytes > totalBytes {
target := totalBytes * cacheCleanupKeepPercent / 100
for _, entry := range ordered {
if result.RemainingBytes <= target {
break
}
if err := os.Remove(entry.path); err != nil {
continue
}
result.DeletedFiles++
result.FreedBytes += entry.size
result.RemainingBytes -= entry.size
result.FreedByPool[entry.pool] += entry.size
_ = os.Remove(entry.path + ".fail")
}
}
// 2. 图片原图子配额独立于总量:即使总量没超,也要把原图压回自己的配额,
// 同时清掉旧命名变体和孤儿失败标记。
if _, err := os.Stat(imagesDir); err == nil {
pools := ImageCachePools(imagesDir, originalsMaxBytes)
if sub, err := PruneImageCachePools(pools, 0); err == nil {
result.DeletedFiles += sub.DeletedFiles
result.FreedBytes += sub.FreedBytes
result.RemainingBytes -= sub.FreedBytes
result.FreedByPool["images"] += sub.FreedBytes
}
}
for _, name := range cacheEvictionPoolOrder {
removeEmptyDirsUnder(filepath.Join(cacheDir, name))
}
return result, nil
}
+44 -44
View File
@@ -9,7 +9,7 @@ import (
"time"
)
// writePoolFile ?????????????????????????
// writePoolFile ??????????????? mtime ???????
func writePoolFile(t *testing.T, path string, size int, modTime time.Time) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
@@ -39,42 +39,38 @@ func mustExist(t *testing.T, path string) {
}
}
// currentVariantPath ????????????????.<??x<??q<??>.img??
// ??????????????????????????
// currentVariantPath ???????????????????
// <64 ????>.<?>x<?>q<??>.img
func currentVariantPath(dir, keyChar string, width, height, quality int) string {
key := strings.Repeat(keyChar, sha256HexLength)
return filepath.Join(dir, fmt.Sprintf("%s.%dx%dq%d.img", key, width, height, quality))
}
// TestPruneImageCachePoolsOriginalTTL ????????????????????
// ?? TTL ??????????????????????
func TestPruneImageCachePoolsOriginalTTL(t *testing.T) {
// TestPruneImageCachePoolsKeepsOldFilesUnderQuota ????????????
// ???????????????????????? / TTL ????
func TestPruneImageCachePoolsKeepsOldFilesUnderQuota(t *testing.T) {
imagesDir := t.TempDir()
now := time.Now()
stale := filepath.Join(imagesDir, imageOriginalCacheSubdir, "stale-original")
fresh := filepath.Join(imagesDir, imageOriginalCacheSubdir, "fresh-original")
thumb := currentVariantPath(filepath.Join(imagesDir, imageResizeCacheSubdir), "b", 480, 600, 80)
writePoolFile(t, stale, 400, now.Add(-8*24*time.Hour))
writePoolFile(t, fresh, 400, now.Add(-1*time.Hour))
// ??????????TTL ????
writePoolFile(t, thumb, 400, now.Add(-8*24*time.Hour))
oldOriginal := filepath.Join(imagesDir, imageOriginalCacheSubdir, "old-original")
oldThumb := currentVariantPath(filepath.Join(imagesDir, imageResizeCacheSubdir), "b", 480, 600, 80)
writePoolFile(t, oldOriginal, 400, now.Add(-90*24*time.Hour))
writePoolFile(t, oldThumb, 400, now.Add(-90*24*time.Hour))
pools := ImageCachePools(imagesDir, 0, 7*24*time.Hour)
pools := ImageCachePools(imagesDir, 0)
res, err := PruneImageCachePools(pools, 100*1024*1024)
if err != nil {
t.Fatalf("prune failed: %v", err)
}
if res.DeletedFiles != 1 {
t.Fatalf("deleted files = %d, want 1", res.DeletedFiles)
if res.DeletedFiles != 0 {
t.Fatalf("deleted files = %d, want 0 (age must not trigger deletion)", res.DeletedFiles)
}
mustNotExist(t, stale)
mustExist(t, fresh)
mustExist(t, thumb)
mustExist(t, oldOriginal)
mustExist(t, oldThumb)
}
// TestPruneImageCachePoolsEvictsOriginalsBeforeDerived ????????????
// ????????????????????
// ????????????????
func TestPruneImageCachePoolsEvictsOriginalsBeforeDerived(t *testing.T) {
imagesDir := t.TempDir()
now := time.Now()
@@ -82,13 +78,13 @@ func TestPruneImageCachePoolsEvictsOriginalsBeforeDerived(t *testing.T) {
original := filepath.Join(imagesDir, imageOriginalCacheSubdir, "original")
rendition := filepath.Join(imagesDir, imageRenditionCacheSubdir, "rendition")
thumb := currentVariantPath(filepath.Join(imagesDir, imageResizeCacheSubdir), "c", 480, 600, 80)
// ?????????????????
// ?????????????????????????
writePoolFile(t, original, 1024*1024, now.Add(-72*time.Hour))
writePoolFile(t, rendition, 1024*1024, now.Add(-2*time.Hour))
writePoolFile(t, thumb, 1024*1024, now.Add(-1*time.Hour))
pools := ImageCachePools(imagesDir, 0, 0)
// ?? 3MB????2.4MB -> ????? 1.92MB??
pools := ImageCachePools(imagesDir, 0)
// ?? 3MB??? 2.4MB -> ??? 1.92MB ???
res, err := PruneImageCachePools(pools, 2*1024*1024+400*1024)
if err != nil {
t.Fatalf("prune failed: %v", err)
@@ -104,8 +100,8 @@ func TestPruneImageCachePoolsEvictsOriginalsBeforeDerived(t *testing.T) {
}
}
// TestPruneImageCachePoolsOriginalQuotaKeepsDerived ????????????
// ??????????????
// TestPruneImageCachePoolsOriginalQuotaKeepsDerived ???????????
// ????????????
func TestPruneImageCachePoolsOriginalQuotaKeepsDerived(t *testing.T) {
imagesDir := t.TempDir()
now := time.Now()
@@ -117,8 +113,8 @@ func TestPruneImageCachePoolsOriginalQuotaKeepsDerived(t *testing.T) {
writePoolFile(t, newOriginal, 800, now.Add(-1*time.Hour))
writePoolFile(t, thumb, 800, now.Add(-2*time.Hour))
// ???? 1000 ??????????1600 -> ????800????? 1 ????
pools := ImageCachePools(imagesDir, 1000, 0)
// ???? 1000??? 1600 ?? -> ??? 800??? 1 ???????
pools := ImageCachePools(imagesDir, 1000)
res, err := PruneImageCachePools(pools, 0)
if err != nil {
t.Fatalf("prune failed: %v", err)
@@ -131,24 +127,26 @@ func TestPruneImageCachePoolsOriginalQuotaKeepsDerived(t *testing.T) {
mustExist(t, thumb)
}
// TestPruneImageCachePoolsCleansFailMarkers ????????????????
// ????????????????
// TestPruneImageCachePoolsCleansFailMarkers ???????????????
// ???????????????????????????????????
func TestPruneImageCachePoolsCleansFailMarkers(t *testing.T) {
imagesDir := t.TempDir()
now := time.Now()
entry := filepath.Join(imagesDir, imageOriginalCacheSubdir, "entry")
entryMarker := entry + ".fail"
kept := filepath.Join(imagesDir, imageOriginalCacheSubdir, "kept")
keptMarker := kept + ".fail"
orphanMarker := filepath.Join(imagesDir, imageOriginalCacheSubdir, "orphan.fail")
freshMarker := filepath.Join(imagesDir, imageOriginalCacheSubdir, "fresh.fail")
writePoolFile(t, entry, 900, now.Add(-10*time.Hour))
writePoolFile(t, entryMarker, 35, now.Add(-10*time.Hour))
writePoolFile(t, entry, 900, now.Add(-2*time.Hour))
writePoolFile(t, entryMarker, 35, now.Add(-2*time.Hour))
writePoolFile(t, kept, 300, now.Add(-1*time.Hour))
writePoolFile(t, keptMarker, 35, now.Add(-1*time.Hour))
writePoolFile(t, orphanMarker, 35, now.Add(-48*time.Hour))
writePoolFile(t, freshMarker, 35, now.Add(-1*time.Minute))
// ???? 500????900 ?????????????????
pools := ImageCachePools(imagesDir, 500, 0)
// ???? 500??? 900+300=1200 ???? mtime ????? entry ? 400?
pools := ImageCachePools(imagesDir, 500)
res, err := PruneImageCachePools(pools, 0)
if err != nil {
t.Fatalf("prune failed: %v", err)
@@ -158,26 +156,27 @@ func TestPruneImageCachePoolsCleansFailMarkers(t *testing.T) {
}
mustNotExist(t, entry)
mustNotExist(t, entryMarker)
mustExist(t, kept)
mustExist(t, keptMarker)
mustNotExist(t, orphanMarker)
mustExist(t, freshMarker)
}
// TestPruneImageCachePoolsSkipsTemporaryFiles ????????????????
// TestPruneImageCachePoolsSkipsTemporaryFiles ??????????????
// ?????????????????
func TestPruneImageCachePoolsSkipsTemporaryFiles(t *testing.T) {
imagesDir := t.TempDir()
tmp := filepath.Join(imagesDir, imageOriginalCacheSubdir, "img-1234.tmp")
writePoolFile(t, tmp, 4096, time.Now().Add(-48*time.Hour))
pools := ImageCachePools(imagesDir, 1, 0)
pools := ImageCachePools(imagesDir, 1)
if _, err := PruneImageCachePools(pools, 1); err != nil {
t.Fatalf("prune failed: %v", err)
}
mustExist(t, tmp)
}
// TestPruneImageCachePoolsDropsLegacyResizeVariants ????????.img????
// ?????????????????????????????????????
// TestPruneImageCachePoolsDropsLegacyResizeVariants ?????? .img ??
// ???????????????????????????
func TestPruneImageCachePoolsDropsLegacyResizeVariants(t *testing.T) {
imagesDir := t.TempDir()
resized := filepath.Join(imagesDir, imageResizeCacheSubdir)
@@ -188,7 +187,7 @@ func TestPruneImageCachePoolsDropsLegacyResizeVariants(t *testing.T) {
writePoolFile(t, current, 400, time.Now())
writePoolFile(t, other, 400, time.Now())
pools := ImageCachePools(imagesDir, 0, 0)
pools := ImageCachePools(imagesDir, 0)
res, err := PruneImageCachePools(pools, 0)
if err != nil {
t.Fatalf("prune failed: %v", err)
@@ -201,8 +200,8 @@ func TestPruneImageCachePoolsDropsLegacyResizeVariants(t *testing.T) {
}
}
// TestPruneImageCachePoolsCountsLegacyFlatLayout ??????images/ ????
// ?????????????????
// TestPruneImageCachePoolsCountsLegacyFlatLayout ???????images/ ???
// ???????????????????????
func TestPruneImageCachePoolsCountsLegacyFlatLayout(t *testing.T) {
imagesDir := t.TempDir()
legacy := filepath.Join(imagesDir, "legacy-flat-cache")
@@ -210,7 +209,8 @@ func TestPruneImageCachePoolsCountsLegacyFlatLayout(t *testing.T) {
writePoolFile(t, legacy, 900, time.Now().Add(-30*24*time.Hour))
writePoolFile(t, thumb, 900, time.Now().Add(-30*24*time.Hour))
pools := ImageCachePools(imagesDir, 0, 24*time.Hour)
// ???? 500??????????????????????????
pools := ImageCachePools(imagesDir, 500)
res, err := PruneImageCachePools(pools, 0)
if err != nil {
t.Fatalf("prune failed: %v", err)
+107 -5
View File
@@ -134,7 +134,7 @@ func TestPruneImageCache_ZeroOrNegativeLimit(t *testing.T) {
}
}
func TestSchedulerJobCleanImageCache(t *testing.T) {
func TestSchedulerJobCleanCache(t *testing.T) {
cacheRoot := t.TempDir()
imagesDir := filepath.Join(cacheRoot, "images")
if err := os.MkdirAll(imagesDir, 0o750); err != nil {
@@ -148,15 +148,117 @@ func TestSchedulerJobCleanImageCache(t *testing.T) {
scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, cacheRoot)
// Set limit to 1MB; our file is 2MB -> should be pruned
scheduler.SetImageCachePolicyProvider(func() ImageCachePolicy {
return ImageCachePolicy{TotalBytes: 1 * 1024 * 1024}
scheduler.SetCacheCleanupPolicyProvider(func() CacheCleanupPolicy {
return CacheCleanupPolicy{TotalBytes: 1 * 1024 * 1024}
})
if err := scheduler.jobCleanImageCache(context.Background()); err != nil {
t.Fatalf("jobCleanImageCache failed: %v", err)
if err := scheduler.jobCleanCache(context.Background()); err != nil {
t.Fatalf("jobCleanCache failed: %v", err)
}
if _, err := os.Stat(f); !os.IsNotExist(err) {
t.Fatalf("expected file to be pruned, got err=%v", err)
}
}
// TestPruneCacheEvictionOrder 验证总量超限时严格按
// hls → images → library-covers 的顺序淘汰,且 reader-audio 不参与。
func TestPruneCacheEvictionOrder(t *testing.T) {
cacheRoot := t.TempDir()
now := time.Now()
audio := filepath.Join(cacheRoot, "reader-audio", "old.mp3")
hls := filepath.Join(cacheRoot, "hls", "job", "seg.ts")
image := filepath.Join(cacheRoot, "images", "originals", "poster.jpg")
cover := filepath.Join(cacheRoot, "library-covers", "cover.jpg")
for _, p := range []string{audio, hls, image, cover} {
writePoolFile(t, p, 1024*1024, now.Add(-time.Hour))
}
// 计入总量的只有 hls+images+cover = 3MB,上限 2MB -> 回落到 1.6MB:
// 按顺序删 hls、images 两个文件即可;reader-audio 与封面都应保留。
res, err := PruneCache(cacheRoot, 2*1024*1024, 0)
if err != nil {
t.Fatalf("prune failed: %v", err)
}
if res.DeletedFiles != 2 {
t.Fatalf("deleted files = %d, want 2", res.DeletedFiles)
}
mustExist(t, audio)
mustNotExist(t, hls)
mustNotExist(t, image)
mustExist(t, cover)
if _, ok := res.FreedByPool["reader-audio"]; ok {
t.Fatalf("reader-audio must not be part of the global pool, got %+v", res.FreedByPool)
}
if res.FreedByPool["hls"] != 1024*1024 || res.FreedByPool["images"] != 1024*1024 {
t.Fatalf("unexpected per-pool stats: %+v", res.FreedByPool)
}
}
// TestPruneCacheLeavesReaderAudioAlone reader-audio 有独立的 1GB 写入时淘汰,
// 不参与总容量统计:即使它单独就超过全局上限,全局任务也不应删它。
func TestPruneCacheLeavesReaderAudioAlone(t *testing.T) {
cacheRoot := t.TempDir()
audio := filepath.Join(cacheRoot, "reader-audio", "book", "chapter.mp3")
writePoolFile(t, audio, 4*1024*1024, time.Now().Add(-time.Hour))
res, err := PruneCache(cacheRoot, 1*1024*1024, 0)
if err != nil {
t.Fatalf("prune failed: %v", err)
}
if res.DeletedFiles != 0 {
t.Fatalf("deleted files = %d, want 0", res.DeletedFiles)
}
if res.TotalBytesBefore != 0 {
t.Fatalf("reader-audio should not count toward total, got %d bytes", res.TotalBytesBefore)
}
mustExist(t, audio)
}
// TestPruneCacheAppliesOriginalsSubQuota 验证总量没超时,图片原图子配额仍然生效。
func TestPruneCacheAppliesOriginalsSubQuota(t *testing.T) {
cacheRoot := t.TempDir()
now := time.Now()
oldOriginal := filepath.Join(cacheRoot, "images", "originals", "old.jpg")
newOriginal := filepath.Join(cacheRoot, "images", "originals", "new.jpg")
derived := currentVariantPath(filepath.Join(cacheRoot, "images", "resize"), "d", 480, 600, 80)
writePoolFile(t, oldOriginal, 800, now.Add(-2*time.Hour))
writePoolFile(t, newOriginal, 800, now.Add(-time.Hour))
writePoolFile(t, derived, 800, now.Add(-time.Hour))
// 总量上限很大(不触发全局淘汰),原图子配额 1000B -> 删到 800B。
res, err := PruneCache(cacheRoot, 100*1024*1024, 1000)
if err != nil {
t.Fatalf("prune failed: %v", err)
}
if res.DeletedFiles != 1 {
t.Fatalf("deleted files = %d, want 1", res.DeletedFiles)
}
mustNotExist(t, oldOriginal)
mustExist(t, newOriginal)
mustExist(t, derived)
}
// TestPruneCacheOldestFirstWithinPool 验证同一池内先删最旧的文件。
func TestPruneCacheOldestFirstWithinPool(t *testing.T) {
cacheRoot := t.TempDir()
now := time.Now()
oldHLS := filepath.Join(cacheRoot, "hls", "job-a", "seg1.ts")
newHLS := filepath.Join(cacheRoot, "hls", "job-b", "seg1.ts")
writePoolFile(t, oldHLS, 1024*1024, now.Add(-2*time.Hour))
writePoolFile(t, newHLS, 1024*1024, now.Add(-time.Minute))
// 上限 1.25MB -> 回落水位 1MB:删掉最旧的 1MB 后正好达标。
res, err := PruneCache(cacheRoot, 1280*1024, 0)
if err != nil {
t.Fatalf("prune failed: %v", err)
}
if res.DeletedFiles != 1 {
t.Fatalf("deleted files = %d, want 1", res.DeletedFiles)
}
mustNotExist(t, oldHLS)
mustExist(t, newHLS)
}
@@ -0,0 +1,33 @@
package service
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestCloud115HLSChildRejectsMismatchedSessionMediaBeforeFetch(t *testing.T) {
fetched := false
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
proxy.client = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
fetched = true
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"video/mp2t"}}, Body: io.NopCloser(strings.NewReader("private-segment")), Request: req}, nil
})}
proxy.sessions["session-b"] = &cloud115HLSSession{
ID: "session-b", MediaID: "media-b", ExpiresAt: time.Now().Add(time.Hour),
entries: map[string]string{"segment": "https://cpats01.115.com/seg.ts"},
}
for _, query := range []string{"?media_id=media-a", ""} {
fetched = false
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/session-b/segment"+query, nil)
rec := httptest.NewRecorder()
err := proxy.ServeChild(context.Background(), rec, req, "session-b", "segment")
if err == nil || fetched {
t.Errorf("query %q must reject the wrong/missing media before fetch; err=%v fetched=%v", query, err, fetched)
}
}
}
+15 -2
View File
@@ -20,8 +20,9 @@ import (
)
var (
ErrCloud115HLSSessionNotFound = errors.New("115 hls session not found")
ErrCloud115HLSUpstreamExpired = errors.New("115 hls upstream expired")
ErrCloud115HLSSessionNotFound = errors.New("115 hls session not found")
ErrCloud115HLSUpstreamExpired = errors.New("115 hls upstream expired")
ErrCloud115HLSSessionMediaMismatch = errors.New("115 hls session media mismatch")
)
const (
@@ -131,6 +132,9 @@ func (p *Cloud115HLSProxy) ServeChild(ctx context.Context, w http.ResponseWriter
if !ok {
return ErrCloud115HLSSessionNotFound
}
if r == nil || strings.TrimSpace(r.URL.Query().Get("media_id")) != session.MediaID {
return ErrCloud115HLSSessionMediaMismatch
}
resp, err := p.fetchUpstream(ctx, upstream, r.Header.Get("Range"))
if err != nil {
return err
@@ -186,6 +190,15 @@ func (p *Cloud115HLSProxy) ServeChild(ctx context.Context, w http.ResponseWriter
return err
}
// SessionMediaID resolves the server-owned media identity before handler authorization.
func (p *Cloud115HLSProxy) SessionMediaID(sessionID, key string) (string, error) {
session, _, ok := p.lookup(sessionID, key)
if !ok {
return "", ErrCloud115HLSSessionNotFound
}
return session.MediaID, nil
}
func (p *Cloud115HLSProxy) newSession(mediaID string, definition int) *cloud115HLSSession {
now := time.Now()
return &cloud115HLSSession{
+24 -6
View File
@@ -104,11 +104,13 @@ func (s *Cloud115PlaybackService) HLSProxy() *Cloud115HLSProxy {
return s.proxy
}
// PlaybackInfo 返回媒体在 direct/115-cloud/local 三种模式下的能力描述。
//
// requestedDefinition 为空或 0 时默认选择 1080P;该档位不可用时返回
// transcoding 状态(并只触发一次加速转码,避免轮询风暴)。
// PlaybackInfo only reads direct/115-cloud/local playback capabilities.
// Cloud work is queued exclusively by StartTranscode, never by polling.
func (s *Cloud115PlaybackService) PlaybackInfo(ctx context.Context, mediaID string, requestedDefinition int) (*PlaybackInfo, error) {
return s.playbackInfo(ctx, mediaID, requestedDefinition, false)
}
func (s *Cloud115PlaybackService) playbackInfo(ctx context.Context, mediaID string, requestedDefinition int, startTranscode bool) (*PlaybackInfo, error) {
if s == nil || s.repo == nil || s.repo.Media == nil {
return nil, ErrMediaNotFound
}
@@ -179,13 +181,26 @@ func (s *Cloud115PlaybackService) PlaybackInfo(ctx context.Context, mediaID stri
return info, nil
}
info.Transcode = s.ensureCloudTranscode(ctx, account.ID, pickCode, selected, client)
if startTranscode {
info.Transcode = s.ensureCloudTranscode(ctx, account.ID, pickCode, selected, client)
} else {
info.Transcode = Cloud115TranscodeState{
State: "idle", Definition: selected.ID, Message: "该清晰度尚未就绪,可请求 115 云端转码", RetryAfterSec: 5,
}
s.mu.Lock()
if last, ok := s.pushState[account.ID+":"+pickCode]; ok {
info.Transcode.State = "transcoding"
info.Transcode.Message = last.message
info.Transcode.StartedAt = last.at.Unix()
}
s.mu.Unlock()
}
return info, nil
}
// StartTranscode 显式触发某个清晰度的云端转码,返回最新状态。
func (s *Cloud115PlaybackService) StartTranscode(ctx context.Context, mediaID string, definition int) (*PlaybackInfo, error) {
return s.PlaybackInfo(ctx, mediaID, definition)
return s.playbackInfo(ctx, mediaID, definition, true)
}
// ResolveCloud115URL 返回指定清晰度的 115 云端 m3u8 地址。
@@ -258,6 +273,9 @@ func (s *Cloud115PlaybackService) ensureCloudTranscode(
}
}
}
// Reserve the in-flight attempt before releasing the lock. Two simultaneous
// explicit starts must not both submit a push while the first awaits 115.
s.pushState[key] = cloud115PushAttempt{at: now, message: "正在提交 115 云端转码请求…"}
s.mu.Unlock()
err := client.SubmitVideoPush(ctx, pickCode, "vip_push")
+10 -3
View File
@@ -16,7 +16,7 @@ import (
"github.com/truewhile/MeBox/internal/service/cloud115"
)
func TestCloud115PlaybackInfoTriggersTranscodeOnce(t *testing.T) {
func TestCloud115PlaybackInfoIsReadOnlyAndStartTriggersTranscodeOnce(t *testing.T) {
var playCalls int32
var pushCalls int32
var ready int32
@@ -93,9 +93,16 @@ func TestCloud115PlaybackInfoTriggersTranscodeOnce(t *testing.T) {
}
svc := NewCloud115PlaybackService(nil, zap.NewNop(), repos, strm)
info, err := svc.PlaybackInfo(ctx, media.ID, 0)
if _, err := svc.PlaybackInfo(ctx, media.ID, 0); err != nil {
t.Fatalf("read playback info: %v", err)
}
if got := atomic.LoadInt32(&pushCalls); got != 0 {
t.Fatalf("read-only playback info triggered %d cloud transcode pushes", got)
}
info, err := svc.StartTranscode(ctx, media.ID, 0)
if err != nil {
t.Fatalf("first playback info: %v", err)
t.Fatalf("start transcode: %v", err)
}
if info.Provider != model.StrmProvider115 || info.DefaultQuality != "4" {
t.Fatalf("unexpected playback info: %#v", info)
@@ -0,0 +1,77 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
func TestCloud115ConcurrentStartsDoNotDuplicateInFlightPush(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
var releaseOnce sync.Once
var pushCalls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if pushCalls.Add(1) == 1 {
close(started)
}
<-release
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"state": true, "code": 0, "data": []any{}})
}))
defer server.Close()
defer releaseOnce.Do(func() { close(release) })
oldBase := cloud115.ProAPIBase
cloud115.ProAPIBase = server.URL
defer func() { cloud115.ProAPIBase = oldBase }()
client := cloud115.NewOpenClient("review-app", "access", "refresh")
svc := NewCloud115PlaybackService(nil, zap.NewNop(), nil, nil)
quality := PlaybackQuality{ID: "4", Label: "1080P", Source: "cloud"}
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
first := make(chan struct{})
go func() { defer close(first); svc.ensureCloudTranscode(ctx, "account", "pick", quality, client) }()
select {
case <-started:
case <-ctx.Done():
t.Fatal("first push did not reach the local server")
}
second := make(chan Cloud115TranscodeState, 1)
go func() { second <- svc.ensureCloudTranscode(ctx, "account", "pick", quality, client) }()
secondReceived := false
select {
case state := <-second:
secondReceived = true
if state.State != "transcoding" {
t.Errorf("in-flight request should be reported as transcoding: %#v", state)
}
case <-time.After(250 * time.Millisecond):
t.Error("second start issued another request instead of reusing the in-flight push")
}
releaseOnce.Do(func() { close(release) })
select {
case <-first:
case <-ctx.Done():
t.Fatal("first push did not finish")
}
// The duplicate path is awaited before restoring the package-wide fake API.
if !secondReceived {
select {
case <-second:
case <-ctx.Done():
t.Fatal("second start did not finish")
}
}
if got := pushCalls.Load(); got != 1 {
t.Fatalf("pushes=%d, want 1", got)
}
}
@@ -0,0 +1,254 @@
package service
import (
"context"
"fmt"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// resetEmbyInvalThrottle 清掉全局失效节流,让测试能断言「这一次调用到底有
// 没有真的清缓存」。测试结束后恢复原值,避免影响同包其他用例。
func resetEmbyInvalThrottle(t *testing.T) {
t.Helper()
embyInvalMu.Lock()
previous := embyInvalLast
embyInvalLast = time.Time{}
embyInvalMu.Unlock()
t.Cleanup(func() {
embyInvalMu.Lock()
embyInvalLast = previous
embyInvalMu.Unlock()
})
}
func newCachedTestEmbyService(t *testing.T) *EmbyService {
t.Helper()
svc := newTestEmbyService(t)
cache := NewRuntimeCacheService(&config.Config{}, zap.NewNop())
t.Cleanup(func() { _ = cache.Close() })
return svc.SetRuntimeCache(cache)
}
// TestRecordProgressKeepsEmbyPayloadCache 覆盖首页加载慢的核心回归点:
// 播放期间客户端每 5-10 秒上报一次进度,如果每次都清空 media:emby:*,
// 客户端刷新首页时 73 个媒体库的 Latest 会全部回源重建(生产环境实测
// 首批 8-25s)。普通进度上报必须保留载荷缓存;只有「已看完」翻转才失效。
func TestRecordProgressKeepsEmbyPayloadCache(t *testing.T) {
svc := newCachedTestEmbyService(t)
const mediaID = "ep-1"
media := model.Media{
Base: model.Base{ID: mediaID},
Title: "第 1 集",
Path: `/media/tv/Show/Season 01/S01E01.mkv`,
DurationSec: 100,
}
if err := svc.repo.DB.Create(&media).Error; err != nil {
t.Fatalf("create media: %v", err)
}
key := svc.embyLatestCacheKey("user-1", "", 16)
svc.cache.SetJSONWithStale(t.Context(), key, embyLatestCacheValue{
Items: []map[string]any{{"Id": "cached-item"}},
}, time.Minute, time.Hour)
resetEmbyInvalThrottle(t)
// 60s / 100s:播放中途
if err := svc.RecordProgressWithSession(t.Context(), "user-1", mediaID, 600_000_000, 1_000_000_000, "session-1"); err != nil {
t.Fatalf("record progress: %v", err)
}
if _, stale, ok := svc.cachedLatestItemsWithStale(t.Context(), key); !ok || stale {
t.Fatal("普通进度上报不应让 Emby 列表载荷缓存失效")
}
resetEmbyInvalThrottle(t)
// 98s / 100s:进入片尾 30s 内的「已看完」区间
if err := svc.RecordProgressWithSession(t.Context(), "user-1", mediaID, 980_000_000, 1_000_000_000, "session-2"); err != nil {
t.Fatalf("record completed progress: %v", err)
}
if _, _, ok := svc.cachedLatestItemsWithStale(t.Context(), key); ok {
t.Fatal("看完状态翻转后应让 Emby 列表载荷缓存失效")
}
}
// TestFindSeriesGroupCachesSiblingVirtualSeries 覆盖 N+1 回归点:一次全表
// 扫描算出了整库的剧集分组,若只缓存命中的那一部,同一页的 16 张卡片和
// 16 张海报会各自重扫一遍 media 表。批量记忆后同批分组都应可命中。
func TestFindSeriesGroupCachesSiblingVirtualSeries(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "剧集", Path: `/media/tv`, Type: "tv", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
shows := []string{"Alpha", "Bravo", "Charlie"}
rows := make([]model.Media, 0, len(shows)*2)
for _, show := range shows {
for ep := 1; ep <= 2; ep++ {
rows = append(rows, model.Media{
Base: model.Base{ID: show + "-" + strconv.Itoa(ep)},
LibraryID: lib.ID,
Title: show,
Path: fmt.Sprintf("/media/tv/%s/Season 01/S01E%02d.mkv", show, ep),
SeasonNum: 1,
EpisodeNum: ep,
})
}
}
for i := range rows {
if err := svc.repo.DB.Create(&rows[i]).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
seriesIDs := map[string]string{}
for i := range rows {
if rows[i].EpisodeNum == 1 {
seriesIDs[rows[i].Title] = svc.seriesIDForMedia(t.Context(), &rows[i])
}
}
target, ok := seriesIDs["Alpha"]
if !ok {
t.Fatal("missing fixture series")
}
if _, found, err := svc.findSeriesGroup(t.Context(), target, ""); err != nil || !found {
t.Fatalf("findSeriesGroup(%s) = (%v, %v), want found", target, found, err)
}
for show, id := range seriesIDs {
if show == "Alpha" {
continue
}
if _, cached := svc.cachedSeriesGroup(id); !cached {
t.Fatalf("同一次扫描算出的 %s 应一并进入缓存,否则每张卡片/海报都会重扫整库", show)
}
}
}
// TestLatestPayloadKeepsStaleCopyForIdleClients 保证「最新添加」载荷在新鲜期
// 之后仍有可用的陈旧副本:客户端空闲很久后再打开首页时,先拿陈旧结果并由
// 后台重建,而不是让全部媒体库同时回源。
func TestLatestPayloadKeepsStaleCopyForIdleClients(t *testing.T) {
svc := newCachedTestEmbyService(t)
key := svc.embyLatestCacheKey("user-1", "", 16)
svc.cache.SetJSONWithStale(t.Context(), key, embyLatestCacheValue{
Items: []map[string]any{{"Id": "stale-item"}},
}, time.Nanosecond, embyLatestStaleWindow)
time.Sleep(2 * time.Millisecond)
items, stale, ok := svc.cachedLatestItemsWithStale(t.Context(), key)
if !ok || !stale {
t.Fatalf("expected a stale hit, got ok=%v stale=%v", ok, stale)
}
if len(items) != 1 || items[0]["Id"] != "stale-item" {
t.Fatalf("unexpected stale payload: %#v", items)
}
}
// mediaScanCounter 统计真正落到 media 表上的查询次数,用来断言「整库数据
// 只扫一次」:GORM 的 Trace 回调里能拿到最终 SQL。
type mediaScanCounter struct {
logger.Interface
mu sync.Mutex
scans int
}
func (c *mediaScanCounter) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
sql, _ := fc()
if strings.Contains(sql, "FROM `media`") {
c.mu.Lock()
c.scans++
c.mu.Unlock()
}
}
func (c *mediaScanCounter) Scans() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.scans
}
// TestVirtualSeriesLookupsShareOneScan 覆盖冷启动风暴的回归点:客户端刷新
// 首页时 73 个库的卡片和海报各自要一个虚拟 series 分组。修好之后,第一次
// 扫描把整库分组都写进缓存,同一页剩下的查询都不该再落 SQL;随后一个不存在
// 的虚拟 ID 也不该反复触发整库重扫。
func TestVirtualSeriesLookupsShareOneScan(t *testing.T) {
counter := &mediaScanCounter{Interface: logger.Discard}
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: counter})
if err != nil {
t.Fatalf("open db: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() { _ = sqlDB.Close() })
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
svc := NewEmbyService(&config.Config{}, zap.NewNop(), repository.New(db))
lib := model.Library{Name: "剧集", Path: `/media/tv`, Type: "tv", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
shows := []string{"Alpha", "Bravo", "Charlie"}
rows := make([]model.Media, 0, len(shows)*2)
for _, show := range shows {
for ep := 1; ep <= 2; ep++ {
rows = append(rows, model.Media{
Base: model.Base{ID: show + "-" + strconv.Itoa(ep)},
LibraryID: lib.ID,
Title: show,
Path: fmt.Sprintf("/media/tv/%s/Season 01/S01E%02d.mkv", show, ep),
SeasonNum: 1,
EpisodeNum: ep,
})
}
}
for i := range rows {
if err := svc.repo.DB.Create(&rows[i]).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
seriesIDs := map[string]string{}
for i := range rows {
if rows[i].EpisodeNum == 1 {
seriesIDs[rows[i].Title] = svc.seriesIDForMedia(t.Context(), &rows[i])
}
}
if _, found, err := svc.findSeriesGroup(t.Context(), seriesIDs["Alpha"], ""); err != nil || !found {
t.Fatalf("findSeriesGroup(Alpha) = (%v, %v), want found", found, err)
}
if got := counter.Scans(); got != 1 {
t.Fatalf("首次查询应恰好扫描 media 一次,实际 %d 次", got)
}
for _, show := range []string{"Bravo", "Charlie"} {
if _, found, err := svc.findSeriesGroup(t.Context(), seriesIDs[show], ""); err != nil || !found {
t.Fatalf("findSeriesGroup(%s) = (%v, %v), want found", show, found, err)
}
}
if got := counter.Scans(); got != 1 {
t.Fatalf("同批分组应命中缓存,不应再扫 media,实际累计 %d 次", got)
}
if _, found, err := svc.findSeriesGroup(t.Context(), embyVirtualSeriesPrefix+"missing", ""); err != nil || found {
t.Fatalf("未知虚拟 ID 应为 not found,实际 found=%v err=%v", found, err)
}
if got := counter.Scans(); got != 1 {
t.Fatalf("刚扫过整库后,未知 ID 不应重扫 media,实际累计 %d 次", got)
}
}
+37 -1
View File
@@ -56,6 +56,17 @@ type EmbyService struct {
virtualSeries map[string]embySeriesCacheEntry
virtualSeasons map[string]embySeasonCacheEntry
virtualArtwork map[string]embyArtworkCacheEntry
// virtualGroups 把「整库虚拟剧集分组」的扫描折叠成一次。
//
// 客户端刷新首页时会同时请求 73 个库的 Latest,每个库的卡片和海报都要
// 各自的虚拟 series 分组。不同剧的分组缓存键不同,singleflight 键也不同,
// 于是第一波请求会并发重扫整张 media 表(生产环境实测冷启动 14-17s)。
// 按用户折叠成一次扫描后,同一波请求共享结果并整批进缓存。
virtualGroups singleflight.Group
// virtualScanAt 记录每个用户最近一次整库扫描的时间,用于避免为同一个
// 不存在的 id 反复重扫(必须按用户区分:可见性不同,别人的快照不能当
// 自己的答案)。由 virtualMu 保护。
virtualScanAt map[string]time.Time
visibilityMu sync.RWMutex
visibilityCache map[string]embyVisibilityCacheEntry
@@ -186,6 +197,15 @@ const (
embyVirtualSeriesCap = 8000
embyVirtualSeasonCap = 16000
embyVirtualArtworkCap = 24000
// embySeriesGroupBulkCacheEpisodes 限制一次批量记忆的剧集行数。
//
// 批量记忆把「一次扫描服务整页卡片 + 整页海报」的收益拿满,但同时让整库
// 剧集常驻内存。超过该量级时退回只缓存命中的那一部,避免大库吃光内存。
embySeriesGroupBulkCacheEpisodes = 8000
// embyVirtualScanGrace 是「刚做过整库虚拟剧集扫描」的保护窗口。窗口内再
// 请求一个不在缓存里的虚拟 series ID,直接按不存在处理,避免为同一个
// 未知 id 反复重扫整库。
embyVirtualScanGrace = 10 * time.Second
// Clients that already cached the 1x1 placeholder treat a stable tag as
// immutable. Virtual ids are the ones that served that placeholder, so
// only those tags get a suffix that forces a refetch.
@@ -325,8 +345,24 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
}
}
// 客户端刷新首页时常用 Movie,Series,Video 这种混合类型一次取「最新」行。
// 只有剧集库才该折叠成剧集卡片:seriesItemsForLibrary 只查
// (season_num>0 OR episode_num>0),电影库走到这里会让整行返回 0 条。
if containsItemType(p.IncludeItemTypes, "Series") && !containsItemType(p.IncludeItemTypes, "Episode") {
return e.seriesItemsForLibrary(ctx, p.ParentID, p)
episodic := true
if p.ParentID != "" {
if value, err := e.libraryIsEpisodic(ctx, p.ParentID); err == nil {
episodic = value
}
}
if episodic {
return e.seriesItemsForLibrary(ctx, p.ParentID, p)
}
// 非剧集库:只请求 Series 时确实没有可返回的条目;混合类型则落回
// mediaItems,由它按 Movie 过滤,而不是整行判空。
if !containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Video") {
return emptyItemsEnvelope(p.StartIndex), nil
}
}
// 电影库的「常规浏览」(未指定 IncludeItemTypes): 电影库里偶尔混入按
+10 -1
View File
@@ -6,6 +6,7 @@ import (
"sort"
"strconv"
"strings"
"time"
)
type embyItemsCacheValue struct {
@@ -77,7 +78,15 @@ func (e *EmbyService) embySimilarCacheKey(mediaID, userID string, limit int) str
}
// defaultEmbyLatestCacheTTLSeconds 是 Emby「最新添加」缓存的兜底时长。
const defaultEmbyLatestCacheTTLSeconds = 300
const defaultEmbyLatestCacheTTLSeconds = 900
// embyLatestStaleWindow 是「最新添加」载荷在进程内保留的陈旧副本时长。
//
// 客户端空闲很久之后再打开首页时,新鲜期早已过去:生产环境观察到这时
// 73 个媒体库的 Latest 同时回源,首批请求要 8-25s 才回来,客户端等不及
// 还会重试,进一步放大压力。保留陈旧副本后,这类请求立刻拿到上次的结果,
// 由后台异步重建(见 refreshLatestItemsAsync),用户不再等 SQL。
const embyLatestStaleWindow = 24 * time.Hour
// embyLatestCacheTTLSeconds 返回「最新添加」列表的缓存时长。它刻意比通用
// 媒体缓存更长:客户端刷新首页时会同时请求全部媒体库的 Latest(生产环境
+1 -1
View File
@@ -153,7 +153,7 @@ func (e *EmbyService) loadLatestItemsCached(ctx context.Context, userID, parentI
}
if e.cache != nil {
freshTTL := time.Duration(e.embyLatestCacheTTLSeconds()) * time.Second
e.cache.SetJSONWithStale(loadCtx, cacheKey, value, freshTTL, freshTTL+30*time.Minute)
e.cache.SetJSONWithStale(loadCtx, cacheKey, value, freshTTL, freshTTL+embyLatestStaleWindow)
}
e.rememberArtworkRefs(value.Artwork)
return value, nil
+130 -40
View File
@@ -41,47 +41,65 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
return embySeriesGroup{}, false, nil
}
if strings.HasPrefix(id, embyVirtualSeriesPrefix) {
if group, ok := e.cachedSeriesGroup(id); ok {
return group, true, nil
}
return e.findVirtualSeriesGroup(ctx, id, userID)
}
var rows []model.Media
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0")
q = e.applyUserMediaVisibility(ctx, q, userID)
if !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
q = q.Where("series_id = ?", id)
} else {
// 虚拟 series ID 只可能来自 series_id 为空的媒体:
// 有 series_id 时分组 key 就是 series_id 本身(UUID,不带虚拟前缀)。
q = q.Where("series_id IS NULL OR series_id = ''")
}
q = q.Where("series_id = ?", id)
if err := q.Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
return embySeriesGroup{}, false, err
}
for _, group := range e.seriesGroupsFromMedia(ctx, rows) {
groups := e.seriesGroupsFromMedia(ctx, rows)
bulkCached := e.rememberScannedSeriesGroups(groups)
for _, group := range groups {
if group.ID == id {
e.rememberSeriesGroup(group)
if !bulkCached {
e.rememberSeriesGroup(group)
}
return group, true, nil
}
}
if !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
if series, err := e.repo.Series.FindByID(ctx, id); err != nil {
return embySeriesGroup{}, false, err
} else if series != nil {
return embySeriesGroup{
ID: series.ID,
LibraryID: series.LibraryID,
Name: series.Title,
PosterURL: series.PosterURL,
BackdropURL: series.BackdropURL,
Overview: series.Overview,
Rating: series.Rating,
Year: series.Year,
TMDbID: series.TMDbID,
BangumiID: series.BangumiID,
CreatedAt: series.CreatedAt,
DateLastMediaAdded: series.CreatedAt,
}, true, nil
if series, err := e.repo.Series.FindByID(ctx, id); err != nil {
return embySeriesGroup{}, false, err
} else if series != nil {
return embySeriesGroup{
ID: series.ID,
LibraryID: series.LibraryID,
Name: series.Title,
PosterURL: series.PosterURL,
BackdropURL: series.BackdropURL,
Overview: series.Overview,
Rating: series.Rating,
Year: series.Year,
TMDbID: series.TMDbID,
BangumiID: series.BangumiID,
CreatedAt: series.CreatedAt,
DateLastMediaAdded: series.CreatedAt,
}, true, nil
}
return embySeriesGroup{}, false, nil
}
// findVirtualSeriesGroup 处理 series_id 为空的剧集分组(虚拟 series ID)。
//
// 虚拟 ID 是 hash(库名 + 剧名),无法反解,只能整库算出分组后按 ID 取用。
// 这里走 loadVirtualSeriesGroups,把同一用户的并发请求折叠成一次扫描:
// 首页一次刷新会同时要 73 个库的卡片和海报,各扫一遍在 2 核主机上要 14-17s。
func (e *EmbyService) findVirtualSeriesGroup(ctx context.Context, id, userID string) (embySeriesGroup, bool, error) {
if group, ok := e.cachedSeriesGroup(id); ok {
return group, true, nil
}
if e.virtualScanRecently(userID) {
return embySeriesGroup{}, false, nil
}
groups, err := e.loadVirtualSeriesGroups(ctx, userID)
if err != nil {
return embySeriesGroup{}, false, err
}
for _, group := range groups {
if group.ID == id {
return group, true, nil
}
}
return embySeriesGroup{}, false, nil
@@ -103,20 +121,14 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
return season, true, nil
}
// 回退:未刮削(series_id 为空,虚拟 key 由库名+名称派生)的媒体只能全量分组。
var rows []model.Media
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("(series_id IS NULL OR series_id = '') AND (season_num > 0 OR episode_num > 0)")
q = e.applyUserMediaVisibility(ctx, q, userID)
if err := q.
Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc").
Limit(embySeriesGroupingLimit).
Find(&rows).Error; err != nil {
// 复用共享扫描:首页并发刷新时整库只扫一次。
groups, err := e.loadVirtualSeriesGroups(ctx, userID)
if err != nil {
return embySeasonGroup{}, false, err
}
for _, series := range e.seriesGroupsFromMedia(ctx, rows) {
for _, series := range groups {
for _, season := range e.seasonsForSeries(series) {
if season.ID == id {
e.rememberSeriesGroup(series)
return season, true, nil
}
}
@@ -186,6 +198,84 @@ func (e *EmbyService) seasonGroupForSeries(ctx context.Context, id, seriesID, us
return embySeasonGroup{}, false, nil
}
// rememberScannedSeriesGroups 批量记住一次扫描得到的全部分组,返回是否真的
// 批量写入(超过剧集预算时不做,调用方需自行缓存命中的那一部)。
//
// 只缓存命中的一部时,同一部剧的多张卡片/海报仍要各扫一次;整批写入后,
// 同一次扫描算出来的其他剧也直接命中,于是整页 16 张卡片 + 16 张海报共用
// 一次全表扫描。
func (e *EmbyService) rememberScannedSeriesGroups(groups []embySeriesGroup) bool {
if e == nil || len(groups) == 0 {
return false
}
episodes := 0
for i := range groups {
episodes += len(groups[i].Episodes)
if episodes > embySeriesGroupBulkCacheEpisodes {
return false
}
}
e.rememberSeriesGroups(groups)
return true
}
// loadVirtualSeriesGroups 返回 series_id 为空的整库剧集分组。
//
// 同一用户的并发调用共享同一次扫描:客户端刷新首页时 73 个库的 Latest 同时
// 进来,每个库的卡片和海报又各自要自己的虚拟 series 分组。分组缓存键各不
// 相同,singleflight 也就不会合并,于是第一波请求把整张 media 表并发重算
// 了 73 次(生产环境实测 14-17s)。这里按用户折叠成一次,结果整批进缓存,
// 调用方拿到切片后按 ID 取用即可。
func (e *EmbyService) loadVirtualSeriesGroups(ctx context.Context, userID string) ([]embySeriesGroup, error) {
if e == nil || e.repo == nil {
return nil, nil
}
v, err, _ := e.virtualGroups.Do("virtual:"+userID, func() (any, error) {
// 与 loadLatestItemsCached 一致:一个客户端断开不应取消正在为其他
// 客户端填充的共享扫描。
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
var rows []model.Media
q := e.repo.DB.WithContext(loadCtx).Model(&model.Media{}).
Where("(series_id IS NULL OR series_id = '') AND (season_num > 0 OR episode_num > 0)")
q = e.applyUserMediaVisibility(loadCtx, q, userID)
if err := q.
Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc").
Limit(embySeriesGroupingLimit).
Find(&rows).Error; err != nil {
return nil, err
}
groups := e.seriesGroupsFromMedia(loadCtx, rows)
e.rememberSeriesGroups(groups)
e.markVirtualScan(userID)
return groups, nil
})
if err != nil {
return nil, err
}
groups, _ := v.([]embySeriesGroup)
return groups, nil
}
// markVirtualScan 记录该用户最近一次整库扫描的时间。
func (e *EmbyService) markVirtualScan(userID string) {
e.virtualMu.Lock()
if e.virtualScanAt == nil {
e.virtualScanAt = make(map[string]time.Time, 4)
}
e.virtualScanAt[userID] = time.Now()
e.virtualMu.Unlock()
}
// virtualScanRecently 报告该用户是否刚做过一次整库扫描。窗口内仍不在缓存里
// 的目标 ID 按不存在处理,避免为同一个未知 ID 反复重扫整库。
func (e *EmbyService) virtualScanRecently(userID string) bool {
e.virtualMu.RLock()
scannedAt := e.virtualScanAt[userID]
e.virtualMu.RUnlock()
return !scannedAt.IsZero() && time.Since(scannedAt) < embyVirtualScanGrace
}
func (e *EmbyService) seriesGroupsFromMedia(ctx context.Context, rows []model.Media) []embySeriesGroup {
byID := map[string]*embySeriesGroup{}
order := []string{}
+29 -3
View File
@@ -26,19 +26,45 @@ func (e *EmbyService) rememberSeriesGroup(group embySeriesGroup) {
if e == nil || strings.TrimSpace(group.ID) == "" {
return
}
e.rememberSeriesGroups([]embySeriesGroup{group})
}
// rememberSeriesGroups 批量记住一组剧集分组(含派生季与封面)。
//
// 一次全表扫描会同时算出整库的剧集分组,而调用方往往只关心其中一部。若只
// 逐个 rememberSeriesGroup,同一次首页刷新里 16 张卡片 + 16 张海报都要各自
// 命中自己的虚拟 series ID,未命中就重扫整张 media 表(生产环境实测单次
// 1.3s,73 个库并发时把连接池排满,首页要 8-25s)。批量写入后冷缓存只付
// 一次扫描,其余全部是 O(1) 命中。
func (e *EmbyService) rememberSeriesGroups(groups []embySeriesGroup) {
if e == nil || len(groups) == 0 {
return
}
expiresAt := time.Now().Add(embyVirtualCacheTTL)
e.virtualMu.Lock()
defer e.virtualMu.Unlock()
if e.virtualSeries == nil {
e.virtualSeries = make(map[string]embySeriesCacheEntry)
e.virtualSeries = make(map[string]embySeriesCacheEntry, len(groups))
}
if e.virtualSeasons == nil {
e.virtualSeasons = make(map[string]embySeasonCacheEntry)
e.virtualSeasons = make(map[string]embySeasonCacheEntry, len(groups))
}
if e.virtualArtwork == nil {
e.virtualArtwork = make(map[string]embyArtworkCacheEntry)
e.virtualArtwork = make(map[string]embyArtworkCacheEntry, len(groups)*2)
}
for _, group := range groups {
e.rememberSeriesGroupLocked(group, expiresAt)
}
// 写入后再裁剪:逐条裁剪会让整批写入退化成 O(n²),且中途裁剪可能把
// 同一批里刚写进去的分组又淘汰掉。
e.trimVirtualCachesLocked(time.Now())
}
// rememberSeriesGroupLocked 写入单个分组,调用方必须已持有 virtualMu。
func (e *EmbyService) rememberSeriesGroupLocked(group embySeriesGroup, expiresAt time.Time) {
if strings.TrimSpace(group.ID) == "" {
return
}
e.virtualSeries[group.ID] = embySeriesCacheEntry{group: group, expiresAt: expiresAt}
e.virtualArtwork[group.ID] = embyArtworkCacheEntry{primary: group.PosterURL, backdrop: group.BackdropURL, expiresAt: expiresAt}
e.virtualArtwork[group.ID+"-bd"] = embyArtworkCacheEntry{primary: group.PosterURL, backdrop: group.BackdropURL, expiresAt: expiresAt}
+27 -2
View File
@@ -2,6 +2,9 @@ package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"io"
"sort"
"strings"
"time"
@@ -278,6 +281,20 @@ func isSupportedEmbyCollectionType(t string) bool {
return false
}
// libraryCoverTag 生成媒体库拼图封面的图片版本号。
//
// 第三方客户端把库主图按 (URL, ImageTag) 缓存,tag 不变就不会重新请求。
// 用成员海报列表的指纹做 tag,库内容变化或生成规则调整时客户端自然会刷新。
func libraryCoverTag(libraryID string, sources []string) string {
h := sha256.New()
_, _ = io.WriteString(h, libraryID)
for _, src := range sources {
_, _ = io.WriteString(h, src)
_, _ = h.Write([]byte{0})
}
return libraryID + "-" + hex.EncodeToString(h.Sum(nil))[:8]
}
func (e *EmbyService) libraryAsView(ctx context.Context, l *model.Library) map[string]any {
collectionType := "movies"
switch l.Type {
@@ -292,9 +309,17 @@ func (e *EmbyService) libraryAsView(ctx context.Context, l *model.Library) map[s
}
// 库封面:与剧集/电影一样,只有在能解析出封面时才广告 ImageTags.Primary,
// 否则客户端会认为该库无主图而不去请求 /Images/Primary。
//
// 拼图封面的 tag 带上「成员海报」的指纹:库内容变化或生成规则调整(例如
// 从 2×2 改成竖切)时 tag 会变,客户端才会丢缓存重新拉图;固定用库 ID
// 会让第三方客户端永远显示第一次缓存下来的封面。
imageTags := map[string]string{}
if e.LibraryHasCover(ctx, l.ID) {
imageTags["Primary"] = l.ID
if sources, lib := LibraryCoverSourcesForLibrary(ctx, e.repo, l.ID); lib != nil {
if len(sources) > 0 {
imageTags["Primary"] = libraryCoverTag(l.ID, sources)
} else if strings.TrimSpace(lib.CoverURL) != "" {
imageTags["Primary"] = l.ID
}
}
return map[string]any{
"Id": l.ID,
+11 -5
View File
@@ -136,7 +136,12 @@ func (e *EmbyService) RecordProgressWithSession(ctx context.Context, userID, med
SessionID: playSessionID,
SessionStartedAtMs: embyPlaySessionStartedAtMs(playSessionID),
})
if err == nil {
// 普通进度上报不清列表载荷缓存。播放期间客户端每 5-10 秒上报一次,
// 每次清空 media:emby:* 会让「最新添加」整批 payload(客户端刷新首页时
// 一次并发 73 个库)反复回源 SQL,生产环境实测因此出现 8-25s 的加载。
// 续播位置由 /Items/Resume 与条目详情实时查库,列表里的进度条允许滞后;
// 只有「已看完」这类状态翻转才让缓存失效(position 到达片尾 30s 内)。
if err == nil && playbackProgressCompleted(pos, dur) {
e.invalidateEmbyItemsCache(ctx)
}
return err
@@ -287,10 +292,11 @@ func applyMeBoxUserData(raw any, history *model.PlaybackHistory, favorite bool)
return userData
}
// embyInvalMu 节流全量缓存失效:播放期间客户端每 5-10s 上报一次进度,
// 每次都 SCAN+DEL 全部 media:emby:* 缓存会把缓存命中率持续打穿(其他
// 客户端每次翻页都回源 SQL)。条目载荷的 UserData 在请求时动态合并,
// 进度类变更做 30s 节流即可,不影响正确性观感。
// embyInvalMu 节流全量缓存失效:收藏/标记已看这类显式操作会改变列表里的
// UserData,必须让 payload 失效。客户端连续点选时会短时间触发多次,节流到
// 30s 一次即可,避免每次都 SCAN+DEL 全部 media:emby:* 缓存。
// 播放进度上报不走这里(见 RecordProgressWithSession):续播位置实时查库,
// 列表里的进度条允许滞后,不值得为它把整批首页载荷打回源 SQL。
var (
embyInvalMu sync.Mutex
embyInvalLast time.Time
@@ -144,3 +144,43 @@ func (c *Container) NormalizePollutedEpisodeMetadata(ctx context.Context) (int,
}
return cleaned, nil
}
// segmentRangeCleanupSettingKey marks the one-time removal of impossible
// skip ranges written by the older end_ms: 0 interpretation.
const segmentRangeCleanupSettingKey = "segment.range_cleanup_v1_done"
// NormalizeImpossibleSegments 删除历史误写进来的「不可能区间」。
//
// 旧解析把 TheIntroDB 的 end_ms: 0 当成「延续到片尾」,于是
// intro:[{start_ms:null,end_ms:0}] 被落成 intro (0, 0),客户端补齐后覆盖整集;
// 同理 recap (0,0) 也会让整集变成回顾。这类行只会让播放器一开播就弹跳过按钮,
// 没有任何保留价值,直接硬删。
//
// 幂等:完成后写 setting 标记。只处理片头/回顾——片尾类允许开放结束点。
func (c *Container) NormalizeImpossibleSegments(ctx context.Context) (int, error) {
if c == nil || c.Repo == nil || c.Repo.DB == nil {
return 0, nil
}
if c.Repo.Setting != nil {
if v, err := c.Repo.Setting.Get(ctx, segmentRangeCleanupSettingKey); err == nil && strings.TrimSpace(v) == "true" {
return 0, nil
}
}
res := c.Repo.DB.WithContext(ctx).Unscoped().
Where("kind IN ?", []string{model.SegmentKindIntro, model.SegmentKindRecap}).
Where("end_ms <= start_ms").
Delete(&model.MediaSegment{})
if res.Error != nil {
return 0, res.Error
}
cleaned := int(res.RowsAffected)
if c.Repo.Setting != nil {
if err := c.Repo.Setting.Set(ctx, segmentRangeCleanupSettingKey, "true"); err != nil && c.Log != nil {
c.Log.Warn("mark segment range cleanup done failed", zap.Error(err))
}
}
if cleaned > 0 && c.Log != nil {
c.Log.Info("impossible skip segments removed", zap.Int("rows", cleaned))
}
return cleaned, nil
}
+109
View File
@@ -0,0 +1,109 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"unicode"
)
var ErrInvalidHLSPlaybackScope = errors.New("invalid HLS playback scope")
var ErrHLSGenerationSuperseded = errors.New("HLS playback generation superseded")
// HLSPlaybackScope separates a media's independent users and player instances.
// UserID must come from server authentication, never from a request parameter.
type HLSPlaybackScope struct {
UserID string
SessionID string
}
func HLSPlaybackScopeForRequest(r *http.Request, userID string) (HLSPlaybackScope, error) {
scope := HLSPlaybackScope{UserID: strings.TrimSpace(userID)}
if scope.UserID == "" || r == nil {
return scope, ErrInvalidHLSPlaybackScope
}
for _, key := range []string{"session_id", "PlaySessionId", "playSessionId", "DeviceId", "deviceId"} {
if value := strings.TrimSpace(r.URL.Query().Get(key)); value != "" {
scope.SessionID = value
break
}
}
if scope.SessionID == "" {
if device := strings.TrimSpace(r.Header.Get("X-Emby-Device-Id")); device != "" {
scope.SessionID = device
}
}
if len(scope.SessionID) > 256 || strings.IndexFunc(scope.SessionID, unicode.IsControl) >= 0 {
return scope, ErrInvalidHLSPlaybackScope
}
// Clients without a session/device identifier remain compatible but only
// share a legacy task with the same authenticated user, never other users.
return scope, nil
}
func (scope HLSPlaybackScope) jobKey(mediaID string) string {
if scope.UserID == "" && scope.SessionID == "" {
return mediaID // Existing internal callers/tests retain their legacy API.
}
sum := sha256.Sum256([]byte(scope.UserID + "\x00" + mediaID + "\x00" + scope.SessionID))
return "player-" + hex.EncodeToString(sum[:])
}
func hlsPlaybackJobKey(mediaID string, scopes []HLSPlaybackScope) string {
if len(scopes) == 0 {
return mediaID
}
return scopes[0].jobKey(mediaID)
}
func (job *hlsJob) key() string {
if job.jobKey != "" {
return job.jobKey
}
return job.mediaID
}
func (t *TranscoderService) jobPlaylistPath(job *hlsJob) string {
dir := job.outputDir
if dir == "" {
dir = t.HLSDir(job.key())
}
return filepath.Join(dir, "index.m3u8")
}
// hlsOutputDir captures one immutable run directory. Even if a new run starts
// after this check, the request cannot accidentally open its replacement files.
func (t *TranscoderService) hlsOutputDir(key string, generation int64) (string, error) {
t.mu.Lock()
defer t.mu.Unlock()
job := t.jobs[key]
if job == nil {
return "", ErrMediaNotFound
}
if job.seekGen != generation {
return "", ErrHLSGenerationSuperseded
}
t.touchJobLocked(key)
if job.outputDir != "" {
return job.outputDir, nil
}
return t.HLSDir(key), nil
}
func (t *TranscoderService) StopPlayback(mediaID string, scope HLSPlaybackScope) {
key := scope.jobKey(mediaID)
unlock := t.lockJobStart(key)
defer unlock()
t.mu.Lock()
job := t.detachJobLocked(key)
t.mu.Unlock()
waitJobExit(job, 12*time.Second)
// Keep cleanup under the same gate as start: a delayed stop must not wipe
// an output directory created by a concurrent replacement request.
_ = os.RemoveAll(t.HLSDir(key))
}
@@ -0,0 +1,133 @@
package service
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func cachedScopedHLS(t *testing.T, scopes ...HLSPlaybackScope) (*StreamService, *TranscoderService, map[string]string) {
t.Helper()
cfg := &config.Config{}
cfg.Cache.CacheDir = t.TempDir()
cfg.Transcoder.Enabled = true
db := newServiceTestDB(t)
if err := db.Create(&model.Media{Base: model.Base{ID: "movie"}, Path: "unused-cached-source", DurationSec: 60}).Error; err != nil {
t.Fatal(err)
}
transcoder := NewTranscoderService(cfg, zap.NewNop(), repository.New(db), nil)
paths := map[string]string{}
for _, scope := range scopes {
key := scope.jobKey("movie")
dir := filepath.Join(transcoder.HLSDir(key), "run-cached")
if err := os.MkdirAll(dir, 0o750); err != nil {
t.Fatal(err)
}
playlist := filepath.Join(dir, "index.m3u8")
if err := os.WriteFile(playlist, []byte("#EXTM3U\n#EXTINF:4,\nseg_00000.ts\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "seg_00000.ts"), []byte(scope.UserID+"/"+scope.SessionID), 0o600); err != nil {
t.Fatal(err)
}
transcoder.jobs[key] = &hlsJob{mediaID: "movie", jobKey: key, outputDir: dir, subtitleStream: -1, startedAt: time.Now(), cancel: func() {}}
paths[key] = playlist
}
return NewStreamService(cfg, zap.NewNop(), repository.New(db), transcoder), transcoder, paths
}
func TestHLSPlaybackEnsureReusesOnlyMatchingOwnerAndSession(t *testing.T) {
a := HLSPlaybackScope{UserID: "viewer", SessionID: "tab-a"}
b := HLSPlaybackScope{UserID: "viewer", SessionID: "tab-b"}
_, transcoder, paths := cachedScopedHLS(t, a, b)
for _, scope := range []HLSPlaybackScope{a, b} {
playlist, err := transcoder.EnsureJobFromSubtitleQuality(t.Context(), "movie", 0, 0, -1, "", scope)
if err != nil {
t.Fatal(err)
}
if playlist != paths[scope.jobKey("movie")] {
t.Fatalf("scope %#v reused another session's playlist %q", scope, playlist)
}
}
}
func TestHLSPlaybackStopRemovesOnlyItsSessionOutput(t *testing.T) {
a := HLSPlaybackScope{UserID: "viewer", SessionID: "tab-a"}
b := HLSPlaybackScope{UserID: "viewer", SessionID: "tab-b"}
_, transcoder, paths := cachedScopedHLS(t, a, b)
transcoder.StopPlayback("movie", a)
if _, err := os.Stat(paths[a.jobKey("movie")]); !os.IsNotExist(err) {
t.Fatalf("stopped session output must be cleaned up, err=%v", err)
}
if _, err := os.Stat(paths[b.jobKey("movie")]); err != nil {
t.Fatalf("another player's output was removed: %v", err)
}
}
func TestHLSPlaybackIdleCleanupRemovesExpiredSessionOutput(t *testing.T) {
a := HLSPlaybackScope{UserID: "viewer", SessionID: "tab-a"}
b := HLSPlaybackScope{UserID: "viewer", SessionID: "tab-b"}
_, transcoder, paths := cachedScopedHLS(t, a, b)
job := transcoder.jobs[a.jobKey("movie")]
job.lastAccess = time.Now().Add(-2 * time.Minute)
if !transcoder.stopIdleJob(job, time.Minute) {
t.Fatal("expired session was not stopped")
}
if _, err := os.Stat(paths[a.jobKey("movie")]); !os.IsNotExist(err) {
t.Fatalf("expired output was retained: %v", err)
}
if _, err := os.Stat(paths[b.jobKey("movie")]); err != nil {
t.Fatalf("active session output was removed: %v", err)
}
}
func TestHLSPlaybackPropagatesHeaderDeviceIdentityToSegments(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/hls/movie/index.m3u8?token=test", nil)
req.Header.Set("X-Emby-Device-Id", "legacy-device")
scope, err := HLSPlaybackScopeForRequest(req, "viewer")
if err != nil {
t.Fatal(err)
}
svc, _, _ := cachedScopedHLS(t, scope)
rec := httptest.NewRecorder()
if err := svc.ServeHLSPlaylist(rec, req, "movie", scope); err != nil {
t.Fatal(err)
}
var segment string
for _, line := range strings.Split(rec.Body.String(), "\n") {
if strings.HasPrefix(line, "seg_") {
segment = line
break
}
}
u, err := url.Parse(segment)
if err != nil || u.Query().Get("session_id") == "" {
t.Fatalf("playlist must carry the header-only device scope to child URLs: %q err=%v", segment, err)
}
child := httptest.NewRequest(http.MethodGet, "/api/hls/movie/"+segment, nil)
childScope, err := HLSPlaybackScopeForRequest(child, "viewer")
if err != nil {
t.Fatal(err)
}
if childScope.jobKey("movie") != scope.jobKey("movie") {
t.Fatal("child request changed its owner's device scope")
}
childRec := httptest.NewRecorder()
if err := svc.ServeHLSSegment(childRec, child, "movie", "seg_00000.ts", childScope); err != nil {
t.Fatal(err)
}
if childRec.Body.String() != "viewer/"+scope.SessionID {
t.Fatalf("wrong scoped segment: %s", childRec.Body.String())
}
}
+97
View File
@@ -0,0 +1,97 @@
package service
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
)
func TestHLSPlaybackScopesIsolateSegmentReadsAndStop(t *testing.T) {
cfg := &config.Config{}
cfg.Cache.CacheDir = t.TempDir()
transcoder := NewTranscoderService(cfg, zap.NewNop(), nil, nil)
svc := NewStreamService(cfg, zap.NewNop(), nil, transcoder)
a := HLSPlaybackScope{UserID: "user-a", SessionID: "tab-a"}
b := HLSPlaybackScope{UserID: "user-b", SessionID: "tab-a"}
c := HLSPlaybackScope{UserID: "user-a", SessionID: "tab-c"}
for _, scope := range []HLSPlaybackScope{a, b, c} {
key := scope.jobKey("movie")
dir := filepath.Join(cfg.Cache.CacheDir, key, "run-current")
if err := os.MkdirAll(dir, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "seg_00000.ts"), []byte(scope.UserID+"/"+scope.SessionID), 0o600); err != nil {
t.Fatal(err)
}
transcoder.jobs[key] = &hlsJob{mediaID: "movie", jobKey: key, outputDir: dir, seekGen: 100, cancel: func() {}}
}
for _, scope := range []HLSPlaybackScope{a, b, c} {
req := httptest.NewRequest(http.MethodGet, "/api/hls/movie/seg_00000.ts?_seek=100", nil)
rec := httptest.NewRecorder()
if err := svc.ServeHLSSegment(rec, req, "movie", "seg_00000.ts", scope); err != nil {
t.Fatal(err)
}
if got, want := rec.Body.String(), scope.UserID+"/"+scope.SessionID; got != want {
t.Fatalf("segment=%q, want %q", got, want)
}
}
transcoder.StopPlayback("movie", a)
if transcoder.jobs[a.jobKey("movie")] != nil {
t.Fatal("stopped scope is still active")
}
if transcoder.jobs[b.jobKey("movie")] == nil || transcoder.jobs[c.jobKey("movie")] == nil {
t.Fatal("stopping one player affected another user/tab")
}
// A delayed stop from a previous local-playback session must not affect a replacement.
transcoder.StopPlayback("movie", a)
if transcoder.jobs[c.jobKey("movie")] == nil {
t.Fatal("old stop cancelled a new session")
}
}
func TestHLSPlaybackRejectsStaleSegmentGeneration(t *testing.T) {
cfg := &config.Config{}
cfg.Cache.CacheDir = t.TempDir()
transcoder := NewTranscoderService(cfg, zap.NewNop(), nil, nil)
svc := NewStreamService(cfg, zap.NewNop(), nil, transcoder)
scope := HLSPlaybackScope{UserID: "viewer", SessionID: "tab"}
key := scope.jobKey("movie")
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "seg_00000.ts"), []byte("new-generation"), 0o600); err != nil {
t.Fatal(err)
}
transcoder.jobs[key] = &hlsJob{mediaID: "movie", jobKey: key, outputDir: dir, seekGen: 200, cancel: func() {}}
req := httptest.NewRequest(http.MethodGet, "/api/hls/movie/seg_00000.ts?_seek=100", nil)
rec := httptest.NewRecorder()
if err := svc.ServeHLSSegment(rec, req, "movie", "seg_00000.ts", scope); err == nil {
t.Fatal("stale segment request read a replacement generation")
}
if rec.Body.Len() != 0 {
t.Fatal("stale request exposed replacement bytes")
}
}
func TestHLSPlaybackScopeRequiresServerOwner(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/?session_id=tab&user_id=attacker-controlled", nil)
if _, err := HLSPlaybackScopeForRequest(req, ""); err == nil {
t.Fatal("missing authenticated owner was accepted")
}
a, err := HLSPlaybackScopeForRequest(req, "real-owner")
if err != nil {
t.Fatal(err)
}
if a.UserID != "real-owner" {
t.Fatal("client parameter supplied the owner")
}
req = httptest.NewRequest(http.MethodGet, "/?PlaySessionId=emby-session", nil)
b, err := HLSPlaybackScopeForRequest(req, "real-owner")
if err != nil || b.SessionID == "" {
t.Fatalf("Emby session must stay scoped: %#v %v", b, err)
}
}
+17 -9
View File
@@ -49,6 +49,10 @@ type ImageProxy struct {
resizeSemMu sync.Mutex
resizeSem chan struct{}
// resizeQueueWait 是一次请求最多为一格解码槽排队的时长。为 0 时用
// defaultResizeQueueWait;测试会把它调小以免真的等待两秒。
resizeQueueWait time.Duration
// libraryRootsFn returns the configured media library roots so that
// sidecar poster/artwork files stored alongside media (under arbitrary
// per-library paths) are allowed by isAllowedLocalPath. It is provided
@@ -86,6 +90,12 @@ const (
// imageRenditionCacheSubdir 存放挂载 Emby 直接按尺寸产出的成品图:它们
// 已经是客户端要的最终尺寸,和派生缩略图一样属于长期保留的热路径缓存。
imageRenditionCacheSubdir = "renditions"
// defaultResizeQueueWait 是请求为一格解码槽排队的最长时间。一张 3MP
// 海报的解码+缩放约 0.15s,2 核机器两格槽位足以在几秒内消化一整屏
// 海报;等待远好于「槽位忙就回退多兆字节原图」(那样每张图在外部网络
// 下要十几秒)。超时后仍回退原图,保证请求不会被无限期挂住。
defaultResizeQueueWait = 3 * time.Second
)
// NewImageProxy is the constructor.
@@ -189,21 +199,19 @@ func imageResizeConcurrency() int {
return n
}
// Prune removes oldest cached images until disk usage is within the configured limit.
// 原图池按独立配额与保留时长优先淘汰,客户端热路径读取的成品图最后淘汰。
// Prune 只执行图片原图子配额与旧缓存清理;整个磁盘缓存的总量淘汰由调度器的
// cache_cleanup 任务(PruneCache)负责。
func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
if p.cfg == nil || p.cfg.Cache.ImagesMaxSizeMB <= 0 {
if p.cfg == nil {
return PruneImageCacheResult{}, nil
}
var originalsMaxBytes int64
if p.cfg.Cache.ImagesOriginalsMaxSizeMB > 0 {
originalsMaxBytes = int64(p.cfg.Cache.ImagesOriginalsMaxSizeMB) * 1024 * 1024
}
var originalsMaxAge time.Duration
if p.cfg.Cache.ImagesOriginalsTTLHours > 0 {
originalsMaxAge = time.Duration(p.cfg.Cache.ImagesOriginalsTTLHours) * time.Hour
if originalsMaxBytes <= 0 {
return PruneImageCacheResult{}, nil
}
totalBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024
pools := ImageCachePools(p.cacheDir, originalsMaxBytes, originalsMaxAge)
return PruneImageCachePools(pools, totalBytes)
pools := ImageCachePools(p.cacheDir, originalsMaxBytes)
return PruneImageCachePools(pools, 0)
}
+79 -14
View File
@@ -17,6 +17,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
"go.uber.org/zap"
@@ -138,8 +139,8 @@ func fitSize(srcW, srcH, maxW, maxH int) (int, int) {
var errImageResizeTooLarge = errors.New("image source exceeds resize pixel budget")
// resizeImageData 按选项缩放图片并重新编码。返回 unchanged=true 表示原图
// 本身已满足目标尺寸,调用方应直接输出原始字节(不重复编码、不损失质量)。
// resizeImageData 按选项缩放图片并重新编码。返回 unchanged=true 表示源图
// 已经是可直接下发的紧凑字节,调用方应直接输出原始字节(不重复编码)。
func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype string, unchanged bool, err error) {
if len(data) == 0 || !o.active() {
return data, detectContentType(data), true, nil
@@ -151,12 +152,21 @@ func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype strin
if cfg.Width <= 0 || cfg.Height <= 0 {
return nil, "", false, errors.New("invalid image dimensions")
}
// 先用 DecodeConfig 判断是否需要解码整图:图已够小就零成本返回原字节。
// 先用 DecodeConfig 判断是否需要缩放。尺寸已在目标框内时仍要重编码:
// 刮削来源的海报常是 1000×1500、1MB 以上的 PNG,直接原样下发会让一处
// 海报墙就从「几十 KB 缩略图」变成「几十 MB 原图」——外部网络下每张
// 都要十几秒。只有本来就是紧凑 Web 格式(JPEG/WebP)且体积很小的源图
// 才值得零成本直出。
dstW, dstH := fitSize(cfg.Width, cfg.Height, o.MaxWidth, o.MaxHeight)
if dstW == cfg.Width && dstH == cfg.Height {
unchangedSize := dstW == cfg.Width && dstH == cfg.Height
if unchangedSize && isCompactWebFormat(format) && len(data) <= compactImageSkipBytes {
return data, detectContentType(data), true, nil
}
if int64(cfg.Width)*int64(cfg.Height) > imageResizeMaxSourcePixels {
if unchangedSize {
// 尺寸已满足但像素量超出解码预算:宁可直出原图,也不冒 OOM 风险。
return data, detectContentType(data), true, nil
}
return nil, "", false, errImageResizeTooLarge
}
@@ -165,7 +175,12 @@ func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype strin
return nil, "", false, err
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
resizeScaled(dst, src, cfg.Width, cfg.Height)
if unchangedSize {
// 不缩放时省掉一次全图重采样,只做格式转换。
draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src)
} else {
resizeScaled(dst, src, cfg.Width, cfg.Height)
}
// 只有可能带透明的源格式才需要逐像素确认,避免 JPEG 的无谓遍历。
// 写实海报的 PNG 通常比等价 JPEG 大一个数量级,因此在确认不含透明
@@ -231,9 +246,12 @@ func isOpaqueImage(img *image.RGBA) bool {
// resizeSourceKey 生成源图身份键:只覆盖源文件身份(路径 + 大小 + mtime),不含
// 目标尺寸。同一源图的所有尺寸档位共用这个前缀,才能互相列举与派生(小图直接
// 从已缓存的大图缩小,而不是重新解码原图)。
//
// 前缀里的 v3 是缓存格式版本:v2 时期「尺寸已在目标框内」的 PNG 会原样直出、
// 不写缓存,而现在同样条件会重编码成 JPEG,旧缓存与新结果语义不同,必须换键。
func (o imageResizeOptions) resizeSourceKey(sourceID string, stat os.FileInfo) string {
h := sha256.New()
_, _ = fmt.Fprintf(h, "v2|%s", sourceID)
_, _ = fmt.Fprintf(h, "v3|%s", sourceID)
if stat != nil {
_, _ = fmt.Fprintf(h, "|%d|%d", stat.Size(), stat.ModTime().UnixNano())
}
@@ -356,13 +374,53 @@ func (p *ImageProxy) tryAcquireResizeSlot() (func(), bool) {
}
}
// acquireResizeSlotFor 对中等体积的图只尝试一次槽位。忙则放弃缩放,
// 避免背景图墙在 2 核机器上排成数秒。超大原图仍等待,以免直出多兆字节。
func (p *ImageProxy) resizeQueueTimeout() time.Duration {
if p != nil && p.resizeQueueWait > 0 {
return p.resizeQueueWait
}
return defaultResizeQueueWait
}
// acquireResizeSlotFor 对紧凑小图只尝试一次槽位,其余情况最多排队
// resizeQueueTimeout。
//
// 从前这里对 1.5MB 以下的图一律「槽位忙就跳过缩放」,结果是并发加载海报墙
// 时几乎每张图都回退成 1MB 上下的 PNG 原图:一页要传几十 MB,外部网络下单张
// 图就要二十多秒。缩略图本身只有几十 KB,排队几百毫秒明显更划算;只有真正
// 大到会拖慢页面的原图才值得在超时后直出。
func (p *ImageProxy) acquireResizeSlotFor(ctx context.Context, size int64) (func(), bool) {
if size > 0 && size <= resizeQueueBypassBytes {
if size > 0 && size <= compactImageSkipBytes {
return p.tryAcquireResizeSlot()
}
return p.acquireResizeSlot(ctx)
return p.acquireResizeSlotWithin(ctx, p.resizeQueueTimeout())
}
// acquireResizeSlotWithin 在 timeout 内等待一格解码槽;超时或请求取消返回
// false,调用方回退原图。
func (p *ImageProxy) acquireResizeSlotWithin(ctx context.Context, timeout time.Duration) (func(), bool) {
if p == nil {
return func() {}, true
}
if timeout <= 0 {
return p.tryAcquireResizeSlot()
}
p.resizeSemMu.Lock()
if p.resizeSem == nil {
p.resizeSem = make(chan struct{}, imageResizeConcurrency())
}
sem := p.resizeSem
p.resizeSemMu.Unlock()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case sem <- struct{}{}:
return func() { <-sem }, true
case <-timer.C:
return nil, false
case <-ctx.Done():
return nil, false
}
}
// serveResizedFromFile 从 srcPath 读取图片,按选项缩放后写出,并把结果缓存
@@ -472,10 +530,6 @@ func (p *ImageProxy) ensureResizeCache(ctx context.Context, srcPath string, o im
// ponytail: 200KB 覆盖这台机器上的典型海报(平均约 100KB);更大的图仍尝试缩放。
const compactImageSkipBytes = 200 * 1024
// resizeQueueBypassBytes 是“槽位忙就放弃缩放”的上限。超过它的原图继续排队,
// 以免把未压缩的剧照直接发给客户端。
const resizeQueueBypassBytes = 1536 * 1024
// serveCompactOriginal 在源文件已经很小且是浏览器可直接显示的 JPEG/WebP 时
// 跳过解码。返回 false 表示仍应走缩放路径。
func serveCompactOriginal(w http.ResponseWriter, r *http.Request, srcPath string, stat os.FileInfo) bool {
@@ -504,6 +558,17 @@ func isCompactWebImage(header []byte) bool {
string(header[8:12]) == "WEBP"
}
// isCompactWebFormat 判断 image.DecodeConfig 报告的格式是否已经适合直接下发
// 给浏览器。PNG/BMP/GIF 即使尺寸很小也可能有几百 KB,需要重编码成 JPEG。
func isCompactWebFormat(format string) bool {
switch strings.ToLower(format) {
case "jpeg", "jpg", "webp":
return true
default:
return false
}
}
// writeResizeCache 原子写入缩放结果;失败只记日志,不影响本次响应。
// 与 writeImageCache 一样不再持有全局锁:临时文件名唯一、rename 原子,
// 持锁只会把缩略图的写盘和原图的写盘串成一条队。
+120 -10
View File
@@ -123,17 +123,58 @@ func TestResizeImageDataScalesDownAndReencodesAsJPEG(t *testing.T) {
}
}
func TestResizeImageDataLeavesImagesWithinBoundsUntouched(t *testing.T) {
// 尺寸已经在目标框内的 PNG 也必须转成 JPEG。刮削来的海报常是 1000×1500、
// 1MB 级的 PNG;此前这种情况原样直出,一处海报墙就要下载几十 MB,外部网络下
// 每张图要十几秒(线上实测单张 1.3MB / 20 多秒)。
func TestResizeImageDataReencodesPNGWithinBounds(t *testing.T) {
data := encodeTestPNG(t, 200, 300, 255)
out, _, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, MaxHeight: 600})
out, ctype, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, MaxHeight: 600, Quality: 80})
if err != nil {
t.Fatalf("resize: %v", err)
}
if unchanged {
t.Fatal("PNG within the target box must still be re-encoded as a compact web format")
}
if ctype != "image/jpeg" {
t.Fatalf("content type = %q, want image/jpeg", ctype)
}
cfg, format, err := image.DecodeConfig(bytes.NewReader(out))
if err != nil {
t.Fatalf("decode re-encoded image: %v", err)
}
if format != "jpeg" {
t.Fatalf("format = %q, want jpeg", format)
}
if cfg.Width != 200 || cfg.Height != 300 {
t.Fatalf("re-encoded to %dx%d, want the original 200x300 (no upscaling)", cfg.Width, cfg.Height)
}
}
// 已经是紧凑 Web 格式且体积很小的源图仍然零成本直出。
func TestResizeImageDataLeavesSmallCompactSourceUntouched(t *testing.T) {
var buf bytes.Buffer
img := image.NewRGBA(image.Rect(0, 0, 200, 300))
for y := 0; y < 300; y++ {
for x := 0; x < 200; x++ {
img.Set(x, y, color.RGBA{R: uint8(x % 16 * 16), G: uint8(y % 16 * 16), B: 200, A: 255})
}
}
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
t.Fatalf("encode test jpeg: %v", err)
}
data := buf.Bytes()
if len(data) > compactImageSkipBytes {
t.Fatalf("test jpeg is %d bytes, expected a compact source", len(data))
}
out, ctype, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, MaxHeight: 600})
if err != nil {
t.Fatalf("resize: %v", err)
}
if !unchanged {
t.Fatal("expected an image already within bounds to be returned unchanged")
t.Fatal("a compact JPEG already within bounds should be served unchanged")
}
if !bytes.Equal(out, data) {
t.Fatal("expected the original bytes to be returned verbatim")
if !bytes.Equal(out, data) || ctype != "image/jpeg" {
t.Fatalf("expected the original jpeg bytes back, got %d bytes of %q", len(out), ctype)
}
}
@@ -302,7 +343,9 @@ func TestServeResizedFromFileSkipsDecodeForCompactJPEG(t *testing.T) {
}
}
func TestServeResizedFromFileDoesNotQueueWhenResizeBusy(t *testing.T) {
// 排队上限:槽位一直不空时,请求最多等 resizeQueueWait,然后回退原图,
// 不会被无限期挂住。这条约束保护的是请求本身,而不是「不等就直出原图」。
func TestServeResizedFromFileFallsBackWhenResizeQueueStaysBusy(t *testing.T) {
dir := t.TempDir()
src := dir + string(os.PathSeparator) + "backdrop.jpg"
data := make([]byte, compactImageSkipBytes+1024)
@@ -310,7 +353,7 @@ func TestServeResizedFromFileDoesNotQueueWhenResizeBusy(t *testing.T) {
if err := os.WriteFile(src, data, 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache", resizeQueueWait: 50 * time.Millisecond}
var releases []func()
for i := 0; i < imageResizeConcurrency(); i++ {
release, ok := proxy.acquireResizeSlot(context.Background())
@@ -327,10 +370,77 @@ func TestServeResizedFromFileDoesNotQueueWhenResizeBusy(t *testing.T) {
start := time.Now()
handled := proxy.serveResizedFromFile(httptest.NewRecorder(), httptest.NewRequest("GET", "/x?maxWidth=480", nil), src, imageResizeOptions{MaxWidth: 480})
if time.Since(start) > 200*time.Millisecond {
t.Fatal("resize slot was busy but the request still waited in the queue")
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
t.Fatalf("request waited %v with every slot held; the queue wait must be bounded", elapsed)
}
if handled {
t.Fatal("expected a busy resize slot to fall back so the caller can serve the original")
t.Fatal("expected a permanently busy queue to fall back so the caller can serve the original")
}
}
// 槽位只是短暂忙碌时必须排队等待并产出缩略图,而不是直出多兆字节原图。
// 这是线上「海报墙每张图 20 多秒」的根因:并发加载时槽位全忙,旧逻辑把
// 1MB 级 PNG 原样下发。
// encodeNoisyPNG 生成难以压缩的 PNG:规则色块会被压到 1KB 级别,测不出
// 「大体积 PNG 被原样下发」这条回归。
func encodeNoisyPNG(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
seed := uint32(0x9e3779b9)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
seed = seed*1664525 + 1013904223
img.Set(x, y, color.RGBA{R: uint8(seed >> 24), G: uint8(seed >> 16), B: uint8(seed >> 8), A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode noisy png: %v", err)
}
return buf.Bytes()
}
func TestServeResizedFromFileWaitsForSlotInsteadOfServingOriginal(t *testing.T) {
dir := t.TempDir()
src := dir + string(os.PathSeparator) + "poster.png"
original := encodeNoisyPNG(t, 378, 540)
if len(original) <= compactImageSkipBytes {
t.Fatalf("test poster is %d bytes, want a source above the compact threshold", len(original))
}
if err := os.WriteFile(src, original, 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
var releases []func()
for i := 0; i < imageResizeConcurrency(); i++ {
release, ok := proxy.acquireResizeSlot(context.Background())
if !ok {
t.Fatal("expected a resize slot")
}
releases = append(releases, release)
}
go func() {
time.Sleep(40 * time.Millisecond)
releases[0]()
}()
rec := httptest.NewRecorder()
if !proxy.serveResizedFromFile(rec, httptest.NewRequest("GET", "/x?maxWidth=480&maxHeight=600", nil), src, imageResizeOptions{MaxWidth: 480, MaxHeight: 600}) {
t.Fatal("expected the request to wait for the slot and serve a thumbnail")
}
body := rec.Body.Bytes()
if bytes.Equal(body, original) {
t.Fatal("served the multi-hundred-KB PNG original instead of a re-encoded thumbnail")
}
if rec.Header().Get("Content-Type") != "image/jpeg" {
t.Fatalf("content type = %q, want image/jpeg", rec.Header().Get("Content-Type"))
}
// 同一请求第二次必须直接命中缩放缓存。
rec2 := httptest.NewRecorder()
if !proxy.serveResizedFromFile(rec2, httptest.NewRequest("GET", "/x?maxWidth=480&maxHeight=600", nil), src, imageResizeOptions{MaxWidth: 480, MaxHeight: 600}) {
t.Fatal("expected the cached thumbnail to be reused")
}
if !bytes.Equal(rec2.Body.Bytes(), body) {
t.Fatal("expected the cached thumbnail bytes to be identical")
}
}
+31 -17
View File
@@ -41,15 +41,6 @@ const (
introDBMaxRetryDelay = 3 * time.Second
)
// IntroDBSpan is one resolved skip range, still in provider terms.
// EndMs == 0 means "runs to the end of the media" (TheIntroDB returns
// end_ms: null for end credits); the caller resolves it against the duration.
type IntroDBSpan struct {
Kind string
StartMs int64
EndMs int64
}
// IntroDBService queries TheIntroDB for one media item.
type IntroDBService struct {
log *zap.Logger
@@ -113,7 +104,7 @@ type introDBResponse struct {
// 429/503 会重试一次(社区库在短时间连发下确实会限流)。重试前会先确认调用方
// 的 deadline 还够用;预算不够就直接返回错误,让调用方保留自己的缓存,
// 把「拿不到片段」维持在「少一个跳过按钮」的量级。
func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int) ([]IntroDBSpan, error) {
func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int) ([]SegmentSpan, error) {
if s == nil || s.client == nil {
return nil, errors.New("introdb service nil")
}
@@ -135,9 +126,20 @@ func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int)
}
}
// Source implements SegmentProvider.
func (s *IntroDBService) Source() string { return IntroDBSource }
// FetchSegments implements SegmentProvider. TheIntroDB 以 TMDb id 寻址。
func (s *IntroDBService) FetchSegments(ctx context.Context, q SegmentQuery) ([]SegmentSpan, error) {
if q.IsMovie {
return s.Fetch(ctx, q.TMDbID, 0, 0)
}
return s.Fetch(ctx, q.TMDbID, q.Season, q.Episode)
}
// introDBFetchAttempt 是一次请求的结果:数据或错误,外加「值不值得重试」。
type introDBFetchAttempt struct {
spans []IntroDBSpan
spans []SegmentSpan
err error
retryable bool
retryAfter time.Duration
@@ -246,7 +248,7 @@ func (s *IntroDBService) mediaURL(tmdbID, season, episode int) string {
// parseIntroDBResponse flattens the per-type arrays into spans, preserving the
// intro -> recap -> credits -> preview order so the player sees the earliest
// range first.
func parseIntroDBResponse(body []byte) ([]IntroDBSpan, error) {
func parseIntroDBResponse(body []byte) ([]SegmentSpan, error) {
var raw introDBResponse
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("parse introdb json: %w", err)
@@ -260,7 +262,7 @@ func parseIntroDBResponse(body []byte) ([]IntroDBSpan, error) {
{"credits", raw.Credits},
{"preview", raw.Preview},
}
spans := make([]IntroDBSpan, 0, len(raw.Intro)+len(raw.Credits))
spans := make([]SegmentSpan, 0, len(raw.Intro)+len(raw.Credits))
for _, group := range groups {
for _, r := range group.ranges {
var start int64
@@ -274,12 +276,24 @@ func parseIntroDBResponse(body []byte) ([]IntroDBSpan, error) {
if start < 0 {
start = 0
}
// end == 0 表示「延续到片尾」,是合法值;其余情况 end 必须大于 start,
// 否则这段区间没有任何可跳过的内容,直接丢弃避免在播放器里出现空按钮。
if end != 0 && end <= start {
// 官方规范(theintrodb.org/openapi.yaml,SegmentTimestamp)区分了两种
// 「没有数值」的写法,含义完全不同,必须按指针区分而不能都归零:
// end_ms: null —— Credits/Preview 专用,「一直到片尾」,合法,
// 落成 0 作为哨兵值由客户端结合片长补齐;
// end_ms: 0 —— 「no segment」,即这一段不存在。
// 早期实现把两者都当成「到片尾」,于是 intro:[{start_ms:null,end_ms:0}]
// 被解释成 [0, 片长],整集都落在「片头」区间里:一开播就弹跳过按钮,
// 开了自动跳过更是直接跳到片尾。实测约三分之一的剧集会返回这种占位。
if r.EndMs != nil && end <= start {
continue
}
spans = append(spans, IntroDBSpan{Kind: group.kind, StartMs: start, EndMs: end})
// 只有片尾类区间才允许「开放结束点」(end_ms: null 落成 0)。片头/回顾
// 如果出现 null 结束点,补齐后的区间会覆盖到片尾,等于把整集判成片头,
// 所以宁可不显示也不猜。
if r.EndMs == nil && group.kind != "credits" && group.kind != "preview" {
continue
}
spans = append(spans, SegmentSpan{Kind: group.kind, StartMs: start, EndMs: end})
}
}
return spans, nil
+160
View File
@@ -0,0 +1,160 @@
// Package service — IntroDB (https://introdb.app) segment client.
//
// 与 TheIntroDB 是两家不同的社区库:IntroDB 只接受 IMDb id 定位媒体,
// 用 is_movie 区分电影与剧集,响应把每种区间直接给成 {start_ms,end_ms} 对象
// (而不是按类型分数组),片尾字段名是 outro 而不是 credits,
// 另外还有 post_credits(片尾彩蛋,映射为预告/额外片尾)。
package service
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"go.uber.org/zap"
)
// IntroDBAppBaseURL 是公开只读端点。POST /submit 需要 API key,这里不做写入。
const IntroDBAppBaseURL = "https://api.introdb.app"
// IntroDBAppSource 写入 media_segments.source,与 TheIntroDB 的 "theintrodb" 区分。
const IntroDBAppSource = "introdb"
const (
introDBAppTimeout = 8 * time.Second
introDBAppMaxBodySize = 1 << 20
)
// introDBAppSegment 是 IntroDB 的单个区间对象。null 表示该类型没有数据。
type introDBAppSegment struct {
StartMs *int64 `json:"start_ms"`
EndMs *int64 `json:"end_ms"`
}
// introDBAppResponse 对应 GET /segments。
type introDBAppResponse struct {
IMDbID string `json:"imdb_id"`
MediaType string `json:"media_type"`
IsMovie bool `json:"is_movie"`
Season int `json:"season"`
Episode int `json:"episode"`
Intro *introDBAppSegment `json:"intro"`
Recap *introDBAppSegment `json:"recap"`
Outro *introDBAppSegment `json:"outro"`
PostCredits *introDBAppSegment `json:"post_credits"`
}
// IntroDBAppService 查询 IntroDB 的片段数据。
type IntroDBAppService struct {
log *zap.Logger
client *http.Client
baseURL string
}
// NewIntroDBAppService is the constructor. The client honours environment and
// OS proxy settings so it behaves like the other third-party API clients.
func NewIntroDBAppService(log *zap.Logger) *IntroDBAppService {
return &IntroDBAppService{
log: log,
client: NewExternalHTTPClient(introDBAppTimeout),
baseURL: IntroDBAppBaseURL,
}
}
// SetBaseURL overrides the API root (tests, mirrors).
func (s *IntroDBAppService) SetBaseURL(base string) *IntroDBAppService {
if s != nil && strings.TrimSpace(base) != "" {
s.baseURL = strings.TrimRight(strings.TrimSpace(base), "/")
}
return s
}
// Source implements SegmentProvider.
func (s *IntroDBAppService) Source() string { return IntroDBAppSource }
// FetchSegments implements SegmentProvider.
//
// 没有 IMDb id 时直接返回空:IntroDB 不接受 TMDb id,用别的 id 拼请求只会拿到
// 400,那会被误记成「查询失败」从而保留旧缓存、永远不再刷新。
func (s *IntroDBAppService) FetchSegments(ctx context.Context, q SegmentQuery) ([]SegmentSpan, error) {
if s == nil || s.client == nil || strings.TrimSpace(q.IMDbID) == "" {
return nil, nil
}
endpoint := s.segmentsURL(q)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
// 404 = 库里没有这部片,是正常的「无数据」,不是错误。
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("introdb: unexpected status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, introDBAppMaxBodySize))
if err != nil {
return nil, err
}
return parseIntroDBAppResponse(body)
}
func (s *IntroDBAppService) segmentsURL(q SegmentQuery) string {
values := url.Values{}
values.Set("imdb_id", strings.TrimSpace(q.IMDbID))
if q.IsMovie {
values.Set("is_movie", "true")
} else if q.Season > 0 && q.Episode > 0 {
values.Set("season", strconv.Itoa(q.Season))
values.Set("episode", strconv.Itoa(q.Episode))
}
return s.baseURL + "/segments?" + values.Encode()
}
// parseIntroDBAppResponse 把 IntroDB 的四个区间对象摊平成 spans。
//
// 顺序固定为 intro -> recap -> outro -> post_credits,与 TheIntroDB 的
// intro/recap/credits/preview 对齐,后处理逻辑因此可以对来源无感。
func parseIntroDBAppResponse(body []byte) ([]SegmentSpan, error) {
var raw introDBAppResponse
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("parse introdb json: %w", err)
}
groups := []struct {
kind string
seg *introDBAppSegment
}{
{"intro", raw.Intro},
{"recap", raw.Recap},
{"credits", raw.Outro},
{"preview", raw.PostCredits},
}
spans := make([]SegmentSpan, 0, len(groups))
for _, group := range groups {
if group.seg == nil || group.seg.StartMs == nil || group.seg.EndMs == nil {
continue
}
start, end := *group.seg.StartMs, *group.seg.EndMs
if start < 0 {
start = 0
}
if end <= start {
continue
}
spans = append(spans, SegmentSpan{Kind: group.kind, StartMs: start, EndMs: end})
}
return spans, nil
}
+564
View File
@@ -0,0 +1,564 @@
package service
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"image"
"image/jpeg"
"io"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"golang.org/x/image/draw"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// 媒体库封面拼图(Emby 风格的「封面集」)。
//
// Emby / Jellyfin 在没有管理员自定义封面时,会用库内几张海报拼成一张
// 2×2 的合成图作为库主图(folder image)。MeBox 早先只是挑评分最高的
// 那一张单图,于是每个库的封面看起来都像「随便选了一张」。这里按同样的
// 思路生成拼图,供 Emby 客户端、Web 和 App 共用同一份图片。
const (
// libraryCoverDefaultWidth / Height 是拼图输出的默认画布。
//
// 库条目的 PrimaryImageAspectRatio 报的是 16:9(见 emby_system.go),
// 客户端按这个比例摆卡片;生成同样比例的图可以避免客户端再裁一刀。
libraryCoverDefaultWidth = 480
libraryCoverDefaultHeight = 270
libraryCoverMinWidth = 96
libraryCoverMaxWidth = 1920
libraryCoverAspect = 16.0 / 9.0
// libraryCoverSourceLimit 是拼图实际取用的成员海报数上限。
//
// 封面是竖切分栏:三栏在 16:9 画布上每栏接近 3:5,既保留海报主体,又能
// 让一个库有多部作品可辨识;再多就会把海报裁成很窄的竖条。
libraryCoverSourceLimit = 3
// libraryCoverMaxPanels 是合成函数能接受的最多分栏数(更长的来源列表
// 只取前几张)。留 4 是给「按库类型调栏数」留的余量。
libraryCoverMaxPanels = 4
// libraryCoverCandidatePool 是挑选海报时的候选池大小。只看最近的一批
// 成员,避免为一个库封面扫全库;对「最新入库」的库来说这批就是它最好
// 的那几张。
libraryCoverCandidatePool = 200
libraryCoverQuality = 85
libraryCoverSubdir = "library-covers"
)
// libraryCoverSourceCache 缓存「库 → 成员海报来源」。Emby 客户端进入首页
// 时会对每个库各请求一次主图,把这个查询压到每库 10 分钟一次,避免几十个
// 200 行的列表查询同时打进来。
var libraryCoverSourceCache = struct {
mu sync.Mutex
items map[string]libraryCoverSourceEntry
}{items: make(map[string]libraryCoverSourceEntry)}
type libraryCoverSourceEntry struct {
sources []string
expiresAt time.Time
}
const libraryCoverSourceTTL = 10 * time.Minute
func cachedLibraryCoverSources(libraryID string) ([]string, bool) {
libraryCoverSourceCache.mu.Lock()
defer libraryCoverSourceCache.mu.Unlock()
entry, ok := libraryCoverSourceCache.items[libraryID]
if !ok || time.Now().After(entry.expiresAt) {
if ok {
delete(libraryCoverSourceCache.items, libraryID)
}
return nil, false
}
return entry.sources, true
}
func rememberLibraryCoverSources(libraryID string, sources []string) {
libraryCoverSourceCache.mu.Lock()
defer libraryCoverSourceCache.mu.Unlock()
if len(libraryCoverSourceCache.items) > 4000 {
libraryCoverSourceCache.items = make(map[string]libraryCoverSourceEntry)
}
libraryCoverSourceCache.items[libraryID] = libraryCoverSourceEntry{
sources: sources,
expiresAt: time.Now().Add(libraryCoverSourceTTL),
}
}
// LibraryCoverSources 返回媒体库封面拼图要用的海报来源,最多 limit 张。
//
// 选法与网页旧逻辑一致(库内最近一批成员,按 seriesArtworkScore 取质量
// 最高者),额外按剧集去重,避免四张海报全是同一部剧的不同集。
func LibraryCoverSources(ctx context.Context, repos *repository.Container, libraryID string, limit int) []string {
if repos == nil || repos.Media == nil || strings.TrimSpace(libraryID) == "" {
return nil
}
if limit <= 0 || limit > libraryCoverSourceLimit {
limit = libraryCoverSourceLimit
}
if cached, ok := cachedLibraryCoverSources(libraryID); ok {
if len(cached) > limit {
return cached[:limit]
}
return cached
}
rows, err := repos.Media.ListByLibrariesFilteredNoCount(ctx, []string{libraryID}, 0, libraryCoverCandidatePool,
repository.MediaQueryFilter{IncludeNSFW: true})
if err != nil || len(rows) == 0 {
return nil
}
type candidate struct {
url string
key string
score int
}
candidates := make([]candidate, 0, len(rows))
for i := range rows {
row := rows[i]
raw := strings.TrimSpace(row.PosterURL)
if raw == "" {
raw = strings.TrimSpace(row.BackdropURL)
}
if raw == "" {
continue
}
key := mediaSeriesKey(row)
if key == "" {
key = "id:" + row.ID
}
candidates = append(candidates, candidate{url: raw, key: key, score: seriesArtworkScore(row)})
}
// 评分高的优先;同分保持数据库顺序(最近的在前),封面更贴近「最新入库」。
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score })
out := make([]string, 0, limit)
seenKey := make(map[string]struct{}, limit)
seenURL := make(map[string]struct{}, limit)
for _, item := range candidates {
if len(out) >= limit {
break
}
if _, ok := seenKey[item.key]; ok {
continue
}
if _, ok := seenURL[item.url]; ok {
continue
}
seenKey[item.key] = struct{}{}
seenURL[item.url] = struct{}{}
out = append(out, item.url)
}
if len(out) == 0 {
rememberLibraryCoverSources(libraryID, nil)
return nil
}
rememberLibraryCoverSources(libraryID, out)
return out
}
// LibraryCoverCacheDir 返回拼图缓存目录(与图片代理的 images/ 分开存放,
// 不会被图片缓存的配额清理误删)。
func LibraryCoverCacheDir(cfgCacheDir string) string {
base := strings.TrimSpace(cfgCacheDir)
if base == "" {
base = "./cache"
}
return filepath.Join(base, libraryCoverSubdir)
}
// LibraryCoverImage 生成(或命中缓存)媒体库封面拼图。
//
// width/height 是期望的输出画布;服务端会按 16:9 收敛,返回的图片不会
// 超过该边界。sources 由调用方提供(见 LibraryCoverSources),这样 Emby
// 路由和 Web API 可以共用同一套生成与缓存逻辑。
func (p *ImageProxy) LibraryCoverImage(ctx context.Context, libraryID string, sources []string, width, height int) ([]byte, error) {
if p == nil {
return nil, errors.New("image proxy unavailable")
}
libraryID = strings.TrimSpace(libraryID)
sources = normalizeCoverSources(sources)
if libraryID == "" || len(sources) == 0 {
return nil, errors.New("no library cover sources")
}
outW, outH := libraryCoverCanvas(width, height)
cachePath, sourceKey := p.libraryCoverCachePath(libraryID, sources, outW, outH)
if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 { // #nosec G304 -- cachePath is derived from a content hash.
return data, nil
}
key := fmt.Sprintf("library-cover:%s-%dx%d-%s", libraryID, outW, outH, sourceKey)
v, err, _ := p.fetchGroup.Do(key, func() (any, error) {
if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 { // #nosec G304 -- cachePath is derived from a content hash.
return data, nil
}
data, buildErr := p.buildLibraryCover(ctx, libraryID, sources, outW, outH, cachePath, sourceKey)
if buildErr != nil {
return nil, buildErr
}
return data, nil
})
if err != nil {
return nil, err
}
data, _ := v.([]byte)
if len(data) == 0 {
return nil, errors.New("empty library cover")
}
return data, nil
}
func (p *ImageProxy) buildLibraryCover(ctx context.Context, libraryID string, sources []string, width, height int, cachePath, sourceKey string) ([]byte, error) {
images := p.fetchCoverSources(ctx, sources)
if len(images) == 0 {
return nil, errors.New("no library cover image fetched")
}
// 解码与合成是 CPU 密集操作:和缩略图共用同一组解码槽,避免一次首页
// 刷新把 2 核主机打满。
release, ok := p.acquireResizeSlotWithin(ctx, p.resizeQueueTimeout())
if ok {
defer release()
}
data, err := composeLibraryCover(images, width, height)
if err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil {
return data, nil
}
if err := writeFileAtomic(cachePath, data); err != nil {
p.warn("write library cover cache failed", err)
return data, nil
}
p.pruneLibraryCoverVariants(filepath.Dir(cachePath), libraryID, sourceKey)
return data, nil
}
// fetchCoverSources 并发拉取成员海报(受图片代理自身的缓存/鉴权/SSRF 规则
// 保护)。单张失败只丢弃该张,其余仍然可以拼图。
func (p *ImageProxy) fetchCoverSources(ctx context.Context, sources []string) [][]byte {
out := make([][]byte, len(sources))
var wg sync.WaitGroup
sem := make(chan struct{}, libraryCoverMaxPanels)
fetchCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
for i, raw := range sources {
i, raw := i, raw
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
data, err := p.coverSourceBytes(fetchCtx, raw)
if err != nil || len(data) == 0 {
return
}
out[i] = data
}()
}
wg.Wait()
compacted := make([][]byte, 0, len(out))
for _, data := range out {
if len(data) > 0 {
compacted = append(compacted, data)
}
}
return compacted
}
// coverSourceBytes 读取一张海报来源。
//
// 刮削会把 sidecar 海报存成 /media/... 这类绝对路径,而 fetch 路径只接受
// http(s)(validateURL 要求有 host),直接丢给 Fetch 会全部失败。本地路径
// 按图片代理同一套 isAllowedLocalPath 白名单读盘,远程地址才走 Fetch,
// 这样鉴权、SSRF 校验与磁盘缓存的行为与 /api/img 完全一致。
func (p *ImageProxy) coverSourceBytes(ctx context.Context, raw string) ([]byte, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, errors.New("empty cover source")
}
if isLocalImagePath(raw) {
abs, err := filepath.Abs(filepath.Clean(raw))
if err != nil {
return nil, err
}
if !p.isAllowedLocalPath(abs) {
return nil, errors.New("local cover path not allowed")
}
return os.ReadFile(abs) // #nosec G304 -- path validated against the library roots above.
}
data, _, err := p.Fetch(ctx, raw)
if err != nil {
return nil, err
}
return data, nil
}
// libraryCoverCachePath 生成缓存路径。
//
// 文件名里的 sourceKey 只由成员海报列表决定(不含尺寸):成员变化时换一批
// 文件,同一批成员的多个尺寸变体可以共存,不会被互相删掉。size 单独进文件名,
// 让 Emby(按客户端 maxWidth/maxHeight)与 Web/App(固定档位)各用自己的成品。
func (p *ImageProxy) libraryCoverCachePath(libraryID string, sources []string, width, height int) (string, string) {
h := sha256.New()
for _, src := range sources {
_, _ = io.WriteString(h, src)
_, _ = h.Write([]byte{0})
}
sourceKey := hex.EncodeToString(h.Sum(nil))[:16]
name := fmt.Sprintf("%s-%dx%d-%s.jpg", safeCoverFileID(libraryID), width, height, sourceKey)
return filepath.Join(LibraryCoverCacheDir(p.cacheDirBase()), name), sourceKey
}
func (p *ImageProxy) cacheDirBase() string {
if p != nil && p.cfg != nil {
return p.cfg.Cache.CacheDir
}
return ""
}
// pruneLibraryCoverVariants 清掉同一个库、旧成员列表留下的封面文件(含各
// 尺寸变体),当前成员列表对应的成品全部保留。
func (p *ImageProxy) pruneLibraryCoverVariants(dir, libraryID, keepSourceKey string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
prefix := safeCoverFileID(libraryID) + "-"
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".jpg") {
continue
}
if strings.Contains(name, "-"+keepSourceKey+".jpg") {
continue
}
_ = os.Remove(filepath.Join(dir, name)) // #nosec G304 -- name comes from ReadDir of our own cache dir.
}
}
// safeCoverFileID 让文件名只包含安全字符(库 ID 来自数据库,但仍做一次
// 白名单过滤,避免任何路径分隔符进入文件名)。
func safeCoverFileID(libraryID string) string {
var b strings.Builder
for _, r := range libraryID {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
}
}
if b.Len() == 0 {
return "library"
}
return b.String()
}
func normalizeCoverSources(sources []string) []string {
seen := make(map[string]struct{}, len(sources))
out := make([]string, 0, len(sources))
for _, raw := range sources {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
if _, ok := seen[raw]; ok {
continue
}
seen[raw] = struct{}{}
out = append(out, raw)
if len(out) >= libraryCoverMaxPanels {
break
}
}
return out
}
// libraryCoverCanvas 把请求的尺寸收敛到 16:9 画布,并夹在合理区间内。
func libraryCoverCanvas(width, height int) (int, int) {
if width <= 0 && height <= 0 {
return libraryCoverDefaultWidth, libraryCoverDefaultHeight
}
if width <= 0 {
width = int(float64(height)*libraryCoverAspect + 0.5)
}
if height <= 0 {
height = int(float64(width)/libraryCoverAspect + 0.5)
}
// 客户端给的是一个「不超过」的盒子:按 16:9 装进去。
if float64(width)/float64(height) > libraryCoverAspect {
width = int(float64(height)*libraryCoverAspect + 0.5)
} else {
height = int(float64(width)/libraryCoverAspect + 0.5)
}
if width < libraryCoverMinWidth {
width = libraryCoverMinWidth
height = int(float64(width)/libraryCoverAspect + 0.5)
}
if width > libraryCoverMaxWidth {
width = libraryCoverMaxWidth
height = int(float64(width)/libraryCoverAspect + 0.5)
}
return width, height
}
// composeLibraryCover 把若干张海报拼成库封面:竖切成若干等宽竖条。
//
// - 1 张:铺满整块画布;
// - 2 张:左右各一半(默认形态,对应网页/客户端卡片的分屏观感);
// - 3/4 张:三等分 / 四等分竖条。
//
// 每格按中心裁切铺满(crop-to-fill),这样不同比例的海报都能对齐分栏。
func composeLibraryCover(images [][]byte, width, height int) ([]byte, error) {
if len(images) == 0 {
return nil, errors.New("no images to compose")
}
if width <= 0 || height <= 0 {
return nil, errors.New("invalid cover size")
}
decoded := make([]image.Image, 0, len(images))
for _, data := range images {
img, ok := decodeCoverImage(data)
if ok {
decoded = append(decoded, img)
}
if len(decoded) >= libraryCoverMaxPanels {
break
}
}
if len(decoded) == 0 {
return nil, errors.New("no decodable cover images")
}
canvas := image.NewRGBA(image.Rect(0, 0, width, height))
cells := make([]image.Rectangle, 0, len(decoded))
if len(decoded) == 1 {
cells = append(cells, canvas.Bounds())
} else {
for i := range decoded {
x0 := width * i / len(decoded)
x1 := width * (i + 1) / len(decoded)
cells = append(cells, image.Rect(x0, 0, x1, height))
}
}
for i, cell := range cells {
drawCoverCell(canvas, cell, decoded[i])
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, canvas, &jpeg.Options{Quality: libraryCoverQuality}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// decodeCoverImage 解码一张海报,并对异常大的图做像素上限保护:一次拼图
// 会同时解码 4 张,2 核 2G 的机器上不能任由超大原图把内存吃光。
func decodeCoverImage(data []byte) (image.Image, bool) {
if len(data) == 0 {
return nil, false
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
return nil, false
}
if cfg.Width*cfg.Height > imageResizeMaxSourcePixels {
return nil, false
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, false
}
return img, true
}
// drawCoverCell 把一张图按中心裁切铺满目标格子。
func drawCoverCell(dst *image.RGBA, cell image.Rectangle, src image.Image) {
if src == nil || cell.Dx() <= 0 || cell.Dy() <= 0 {
return
}
bounds := src.Bounds()
srcW, srcH := bounds.Dx(), bounds.Dy()
if srcW <= 0 || srcH <= 0 {
return
}
targetAspect := float64(cell.Dx()) / float64(cell.Dy())
srcAspect := float64(srcW) / float64(srcH)
crop := bounds
if srcAspect > targetAspect {
cropW := int(float64(srcH)*targetAspect + 0.5)
if cropW < 1 {
cropW = 1
}
x0 := bounds.Min.X + (srcW-cropW)/2
crop = image.Rect(x0, bounds.Min.Y, x0+cropW, bounds.Max.Y)
} else if srcAspect < targetAspect {
cropH := int(float64(srcW)/targetAspect + 0.5)
if cropH < 1 {
cropH = 1
}
y0 := bounds.Min.Y + (srcH-cropH)/2
crop = image.Rect(bounds.Min.X, y0, bounds.Max.X, y0+cropH)
}
scale := float64(cell.Dx()) / float64(crop.Dx())
var scaler draw.Scaler = draw.CatmullRom
if scale < imageResizeCheapScaleRatio {
scaler = draw.ApproxBiLinear
}
scaler.Scale(dst, cell, src, crop, draw.Src, nil)
}
// writeFileAtomic 先写临时文件再 rename,避免并发的读取方读到半个文件。
func writeFileAtomic(path string, data []byte) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".cover-*")
if err != nil {
return err
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
_ = os.Remove(tmp.Name())
return err
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmp.Name())
return err
}
if err := os.Rename(tmp.Name(), path); err != nil {
_ = os.Remove(tmp.Name())
return err
}
return nil
}
// LibraryCoverSourcesForLibrary 是给 handler 的便捷封装:库存在且没有自定
// 义封面时返回成员海报来源;远程挂载库由各自的上游负责封面,不在此生成。
func LibraryCoverSourcesForLibrary(ctx context.Context, repos *repository.Container, libraryID string) ([]string, *model.Library) {
if repos == nil || repos.Library == nil {
return nil, nil
}
libraryID = strings.TrimSpace(libraryID)
if libraryID == "" {
return nil, nil
}
lib, err := repos.Library.FindByID(ctx, libraryID)
if err != nil || lib == nil {
return nil, nil
}
if strings.TrimSpace(lib.CoverURL) != "" {
return nil, lib
}
return LibraryCoverSources(ctx, repos, libraryID, libraryCoverSourceLimit), lib
}
+335
View File
@@ -0,0 +1,335 @@
package service
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"sync/atomic"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
func solidJPEG(t *testing.T, c color.Color, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 95}); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
return buf.Bytes()
}
func decodeJPEG(t *testing.T, data []byte) image.Image {
t.Helper()
img, err := jpeg.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("decode composed cover: %v", err)
}
return img
}
// TestComposeLibraryCoverSplitsIntoSideBySideColumns 保证多张海报是竖切成
// 左右分栏(左→右顺序),而不是 2×2 田字格。
func TestComposeLibraryCoverSplitsIntoSideBySideColumns(t *testing.T) {
images := [][]byte{
solidJPEG(t, color.RGBA{R: 220, A: 255}, 200, 300),
solidJPEG(t, color.RGBA{G: 220, A: 255}, 200, 300),
solidJPEG(t, color.RGBA{B: 220, A: 255}, 200, 300),
solidJPEG(t, color.RGBA{R: 220, G: 220, A: 255}, 200, 300),
}
out, err := composeLibraryCover(images, 480, 270)
if err != nil {
t.Fatalf("compose: %v", err)
}
img := decodeJPEG(t, out)
if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 270 {
t.Fatalf("size = %v, want 480x270", img.Bounds())
}
// 四张图 → 四条竖栏;同一栏从顶部到底部颜色一致(竖切,不是上下分割)。
y := img.Bounds().Dy() / 2
checks := []struct {
name string
x int
dominant func(color.RGBA) bool
}{
{"column 1 red", 60, func(c color.RGBA) bool { return c.R > c.G+40 && c.R > c.B+40 }},
{"column 2 green", 180, func(c color.RGBA) bool { return c.G > c.R+40 && c.G > c.B+40 }},
{"column 3 blue", 300, func(c color.RGBA) bool { return c.B > c.R+40 && c.B > c.G+40 }},
{"column 4 yellow", 420, func(c color.RGBA) bool { return c.R > c.B+40 && c.G > c.B+40 }},
}
for _, check := range checks {
for _, yy := range []int{2, y, img.Bounds().Dy() - 3} {
r, g, b, _ := img.At(check.x, yy).RGBA()
got := color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 255}
if !check.dominant(got) {
t.Fatalf("%s at y=%d: pixel = %v", check.name, yy, got)
}
}
}
}
// TestComposeLibraryCoverHandlesFewerPosters 覆盖 1/2/3 张成员的布局。
func TestComposeLibraryCoverHandlesFewerPosters(t *testing.T) {
cases := []struct {
name string
count int
}{
{"single", 1},
{"pair", 2},
{"triple", 3},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
images := make([][]byte, 0, tc.count)
for i := 0; i < tc.count; i++ {
images = append(images, solidJPEG(t, color.RGBA{R: 200, G: 120, A: 255}, 300, 450))
}
out, err := composeLibraryCover(images, 320, 180)
if err != nil {
t.Fatalf("compose: %v", err)
}
img := decodeJPEG(t, out)
if img.Bounds().Dx() != 320 || img.Bounds().Dy() != 180 {
t.Fatalf("size = %v, want 320x180", img.Bounds())
}
})
}
}
// TestComposeLibraryCoverSkipsUndecodableSources 保证一张坏图不会让整张
// 封面失败:其余可解码的成员仍然会拼出来。
func TestComposeLibraryCoverSkipsUndecodableSources(t *testing.T) {
images := [][]byte{
[]byte("not an image"),
solidJPEG(t, color.RGBA{G: 200, A: 255}, 200, 300),
}
out, err := composeLibraryCover(images, 240, 135)
if err != nil {
t.Fatalf("compose: %v", err)
}
if len(out) == 0 {
t.Fatal("expected composed cover bytes")
}
if _, err := composeLibraryCover([][]byte{[]byte("nope")}, 240, 135); err == nil {
t.Fatal("expected error when no source is decodable")
}
}
func TestLibraryCoverCanvasKeepsSixteenByNine(t *testing.T) {
cases := []struct {
inW, inH int
wantW int
wantH int
checkRatio bool
}{
{0, 0, 480, 270, true},
{640, 480, 640, 360, true}, // 4:3 盒子 → 装进 16:9
{2000, 0, 1920, 1080, true}, // 超上限 → 夹紧
{40, 0, 96, 54, true}, // 低于下限 → 抬到最小宽度
{0, 540, 960, 540, false}, // 只给高度 → 按 16:9 推宽度
}
for _, tc := range cases {
w, h := libraryCoverCanvas(tc.inW, tc.inH)
if w != tc.wantW || h != tc.wantH {
t.Fatalf("libraryCoverCanvas(%d,%d) = %dx%d, want %dx%d", tc.inW, tc.inH, w, h, tc.wantW, tc.wantH)
}
if tc.checkRatio && w != 0 && h != 0 {
ratio := float64(w) / float64(h)
if ratio < 1.7 || ratio > 1.85 {
t.Fatalf("ratio for %dx%d = %.3f, want ~1.78", tc.inW, tc.inH, ratio)
}
}
}
}
// TestLibraryCoverSourcesDedupesSeries 保证封面不会两张都是同一部剧的不同集,
// 并且优先取海报、没有海报时退回剧照、完全没有图的成员被跳过。
func TestLibraryCoverSourcesDedupesSeries(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "剧集", Path: "/media/tv", Type: "tv", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
rows := []model.Media{
{Base: model.Base{ID: "a1"}, LibraryID: lib.ID, Title: "A", Path: "/media/tv/A/S01E01.mkv", SeriesID: "series-a", PosterURL: "https://img/a1.jpg"},
{Base: model.Base{ID: "a2"}, LibraryID: lib.ID, Title: "A", Path: "/media/tv/A/S01E02.mkv", SeriesID: "series-a", PosterURL: "https://img/a2.jpg"},
{Base: model.Base{ID: "b1"}, LibraryID: lib.ID, Title: "B", Path: "/media/tv/B/S01E01.mkv", SeriesID: "series-b", PosterURL: "https://img/b1.jpg"},
{Base: model.Base{ID: "c1"}, LibraryID: lib.ID, Title: "C", Path: "/media/tv/C/S01E01.mkv", SeriesID: "series-c", BackdropURL: "https://img/c1-backdrop.jpg"},
{Base: model.Base{ID: "d1"}, LibraryID: lib.ID, Title: "D", Path: "/media/tv/D/S01E01.mkv", SeriesID: "series-d"},
}
for i := range rows {
if err := svc.repo.DB.Create(&rows[i]).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
// 竖切三栏:一部剧只出一张,无图成员被跳过 → 正好 3 张。
sources := LibraryCoverSources(t.Context(), svc.repo, lib.ID, libraryCoverSourceLimit)
if len(sources) != 3 {
t.Fatalf("sources = %v, want 3 (一部剧一张、跳过无图成员)", sources)
}
seen := map[string]bool{}
for _, src := range sources {
if seen[src] {
t.Fatalf("duplicate source in %v", sources)
}
seen[src] = true
}
// 同一部剧只会出现一次:具体保留哪一集由查询顺序决定,但绝不能两张。
if seen["https://img/a1.jpg"] && seen["https://img/a2.jpg"] {
t.Fatalf("same series contributed two posters: %v", sources)
}
if !seen["https://img/a1.jpg"] && !seen["https://img/a2.jpg"] {
t.Fatalf("series A is missing from sources: %v", sources)
}
if !seen["https://img/c1-backdrop.jpg"] {
t.Fatalf("member without poster should fall back to backdrop: %v", sources)
}
}
// TestLibraryCoverImageCachesComposedBytes 验证生成结果会落盘复用:同一份
// 内容第二次请求不再回源。
func TestLibraryCoverImageCachesComposedBytes(t *testing.T) {
var hits int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(solidJPEG(t, color.RGBA{R: 180, G: 140, B: 90, A: 255}, 240, 360))
}))
defer upstream.Close()
parsed, err := url.Parse(upstream.URL)
if err != nil {
t.Fatalf("parse upstream: %v", err)
}
cacheCfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}
proxy := NewImageProxy(cacheCfg, zap.NewNop())
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} })
sources := []string{upstream.URL + "/1.jpg", upstream.URL + "/2.jpg", upstream.URL + "/3.jpg", upstream.URL + "/4.jpg"}
first, err := proxy.LibraryCoverImage(t.Context(), "lib-1", sources, 480, 270)
if err != nil {
t.Fatalf("first cover: %v", err)
}
if img := decodeJPEG(t, first); img.Bounds().Dx() != 480 || img.Bounds().Dy() != 270 {
t.Fatalf("size = %v, want 480x270", img.Bounds())
}
hitsAfterFirst := atomic.LoadInt32(&hits)
if hitsAfterFirst != 4 {
t.Fatalf("upstream hits = %d, want one per source", hitsAfterFirst)
}
second, err := proxy.LibraryCoverImage(t.Context(), "lib-1", sources, 480, 270)
if err != nil {
t.Fatalf("second cover: %v", err)
}
if !bytes.Equal(first, second) {
t.Fatal("cached cover bytes differ from the first build")
}
if got := atomic.LoadInt32(&hits); got != hitsAfterFirst {
t.Fatalf("cache miss: upstream hits %d -> %d", hitsAfterFirst, got)
}
// 内容变了要重新生成(成员海报换了新文件)。已缓存的三张海报不会重下,
// 只有新增的那张会回源。
changed := append([]string{}, sources[:3]...)
changed = append(changed, upstream.URL+"/5.jpg")
if _, err := proxy.LibraryCoverImage(t.Context(), "lib-1", changed, 480, 270); err != nil {
t.Fatalf("cover after content change: %v", err)
}
if got := atomic.LoadInt32(&hits); got != hitsAfterFirst+1 {
t.Fatalf("content change should rebuild with one new fetch, hits = %d", got)
}
// 同一批成员的另一个尺寸档位要能共存:Web/App 与 Emby 请求的尺寸不同,
// 不能互相把对方的成品当「旧内容」删掉。
if _, err := proxy.LibraryCoverImage(t.Context(), "lib-1", changed, 400, 225); err != nil {
t.Fatalf("other size cover: %v", err)
}
entries, err := os.ReadDir(LibraryCoverCacheDir(cacheCfg.Cache.CacheDir))
if err != nil {
t.Fatalf("read cover cache dir: %v", err)
}
files := make([]string, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
files = append(files, entry.Name())
}
}
if len(files) != 2 {
t.Fatalf("expected 480x270 and 400x225 variants to coexist, got %v", files)
}
}
// TestLibraryCoverSourceBytesReadsLocalSidecarPoster 覆盖刮削把海报存成
// /media/... 本地绝对路径的情况:这类来源没有 http(s) host,不能走 Fetch,
// 必须按库根目录白名单直接读盘,否则整张拼图会因为「没有可解码的海报」而
// 生成失败(生产环境所有本地库都是这种来源)。
func TestLibraryCoverSourceBytesReadsLocalSidecarPoster(t *testing.T) {
libDir := t.TempDir()
posterPath := filepath.Join(libDir, "poster.jpg")
if err := os.WriteFile(posterPath, solidJPEG(t, color.RGBA{R: 180, G: 90, A: 255}, 200, 300), 0o644); err != nil {
t.Fatalf("write poster: %v", err)
}
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
// 未登记为库根目录时必须拒绝读取。
if _, err := proxy.coverSourceBytes(t.Context(), posterPath); err == nil {
t.Fatal("expected local path outside the library roots to be rejected")
}
proxy.SetLibraryRootsProvider(func() []string { return []string{libDir} })
data, err := proxy.coverSourceBytes(t.Context(), posterPath)
if err != nil {
t.Fatalf("read local poster: %v", err)
}
if len(data) == 0 {
t.Fatal("expected poster bytes")
}
if _, ok := decodeCoverImage(data); !ok {
t.Fatal("local poster should decode")
}
}
// TestLibraryCoverSourcesForLibrarySkipsCustomCover 保证管理员填了自定义
// 封面时不会再生成拼图(沿用自定义图)。
func TestLibraryCoverSourcesForLibrarySkipsCustomCover(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", CoverURL: "https://example.com/custom.jpg"}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
if err := svc.repo.DB.Create(&model.Media{
Base: model.Base{ID: "m1"},
LibraryID: lib.ID,
Title: "M",
Path: "/media/movies/m1.mkv",
PosterURL: "https://img/m1.jpg",
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
sources, found := LibraryCoverSourcesForLibrary(t.Context(), svc.repo, lib.ID)
if found == nil || found.CoverURL != lib.CoverURL {
t.Fatalf("library = %#v, want the existing library", found)
}
if len(sources) != 0 {
t.Fatalf("expected no generated sources with a custom cover, got %v", sources)
}
if _, blank := LibraryCoverSourcesForLibrary(t.Context(), svc.repo, "missing"); blank != nil {
t.Fatalf("unknown library should return nil, got %#v", blank)
}
}
+165
View File
@@ -0,0 +1,165 @@
package service
import (
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func newManualMarkFixture(t *testing.T) (*MediaSegmentService, *repository.Container, *model.Media) {
t.Helper()
repos := repository.New(newServiceTestDB(t))
svc := NewMediaSegmentService(zap.NewNop(), repos)
m := &model.Media{
Base: model.Base{ID: "ep-1"},
Title: "Show",
Path: "/tv/show/s01e01.mkv",
LibraryID: "lib-1",
SeasonNum: 1,
EpisodeNum: 1,
}
if err := repos.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
return svc, repos, m
}
func TestSetManualMarkWritesIntroBoundary(t *testing.T) {
svc, repos, m := newManualMarkFixture(t)
if err := svc.SetManualMark(t.Context(), m, ManualMarkRequest{
Kind: model.SegmentKindIntro, PositionMs: 90_000,
}); err != nil {
t.Fatalf("set mark: %v", err)
}
rows, err := repos.MediaSegment.ListByMedia(t.Context(), m.ID)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Fatalf("rows = %#v, want 1", rows)
}
if rows[0].Kind != model.SegmentKindIntro || rows[0].Source != SegmentSourceManual {
t.Fatalf("row = %#v, want manual intro", rows[0])
}
if rows[0].StartMs != 0 || rows[0].EndMs != 90_000 {
t.Fatalf("row = %#v, want 0-90000ms", rows[0])
}
}
func TestSetManualMarkClearRemovesOnlyThatKind(t *testing.T) {
svc, repos, m := newManualMarkFixture(t)
ctx := t.Context()
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{Kind: model.SegmentKindIntro, PositionMs: 90_000}); err != nil {
t.Fatal(err)
}
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{Kind: model.SegmentKindCredits, PositionMs: 1_200_000}); err != nil {
t.Fatal(err)
}
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{Kind: model.SegmentKindIntro, Clear: true}); err != nil {
t.Fatal(err)
}
rows, err := repos.MediaSegment.ListByMedia(ctx, m.ID)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Kind != model.SegmentKindCredits {
t.Fatalf("rows = %#v, want only the credits mark to survive", rows)
}
}
func TestSetManualMarkRejectsInvalidRequests(t *testing.T) {
svc, _, m := newManualMarkFixture(t)
cases := []ManualMarkRequest{
{Kind: "recap", PositionMs: 1000}, // 只支持 intro/credits
{Kind: "preview", PositionMs: 1000}, // 同上
{Kind: model.SegmentKindIntro, PositionMs: 0},
{Kind: model.SegmentKindIntro, PositionMs: -5},
}
for _, req := range cases {
if err := svc.SetManualMark(t.Context(), m, req); err == nil {
t.Fatalf("req %#v: want an error", req)
}
}
}
// 同一季的其它集应自动继承手动标记,但用户已显式标过的集不能被覆盖。
func TestSetManualMarkPropagatesToSeasonSiblings(t *testing.T) {
svc, repos, m := newManualMarkFixture(t)
ctx := t.Context()
sibling := &model.Media{
Base: model.Base{ID: "ep-2"},
Title: "Show",
Path: "/tv/show/s01e02.mkv",
LibraryID: "lib-1",
SeasonNum: 1,
EpisodeNum: 2,
}
other := &model.Media{
Base: model.Base{ID: "ep-3"},
Title: "Show",
Path: "/tv/show/s01e03.mkv",
LibraryID: "lib-1",
SeasonNum: 1,
EpisodeNum: 3,
}
if err := repos.DB.Create(sibling).Error; err != nil {
t.Fatal(err)
}
if err := repos.DB.Create(other).Error; err != nil {
t.Fatal(err)
}
// other 已经有自己的手动片头标记,不该被覆盖。
if err := svc.SetManualMark(ctx, other, ManualMarkRequest{
Kind: model.SegmentKindIntro, PositionMs: 45_000,
}); err != nil {
t.Fatal(err)
}
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{
Kind: model.SegmentKindIntro, PositionMs: 90_000,
}); err != nil {
t.Fatal(err)
}
sibRows, err := repos.MediaSegment.ListByMedia(ctx, sibling.ID)
if err != nil {
t.Fatal(err)
}
if len(sibRows) != 1 || sibRows[0].EndMs != 90_000 {
t.Fatalf("sibling rows = %#v, want the propagated 90000ms mark", sibRows)
}
otherRows, err := repos.MediaSegment.ListByMedia(ctx, other.ID)
if err != nil {
t.Fatal(err)
}
if len(otherRows) != 1 || otherRows[0].EndMs != 45_000 {
t.Fatalf("other rows = %#v, want its own 45000ms mark preserved", otherRows)
}
}
// 手动标记优先级最高:即使外部源给了不同位置,播放时也只输出手动那条。
func TestManualMarkOutranksExternalSource(t *testing.T) {
svc, repos, m := newManualMarkFixture(t)
ctx := t.Context()
if err := repos.MediaSegment.ReplaceForMedia(ctx, m.ID, IntroDBSource, []model.MediaSegment{{
MediaID: m.ID, Kind: model.SegmentKindIntro, StartMs: 0, EndMs: 30_000, Source: IntroDBSource,
}}); err != nil {
t.Fatal(err)
}
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{
Kind: model.SegmentKindIntro, PositionMs: 90_000,
}); err != nil {
t.Fatal(err)
}
rows, err := svc.mergeForPlayback(ctx, m.ID)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Source != SegmentSourceManual || rows[0].EndMs != 90_000 {
t.Fatalf("rows = %#v, want the manual mark to win", rows)
}
}
+8 -7
View File
@@ -12,13 +12,14 @@ import (
// MediaService offers high-level CRUD over libraries and media items.
type MediaService struct {
cfg *config.Config
log *zap.Logger
repo *repository.Container
cache *RuntimeCacheService
groupedMediaFlight singleflight.Group
libraryRowsFlight singleflight.Group
recentSeriesFlight singleflight.Group
cfg *config.Config
log *zap.Logger
repo *repository.Container
cache *RuntimeCacheService
groupedMediaFlight singleflight.Group
mediaSortFlight singleflight.Group
libraryRowsFlight singleflight.Group
recentSeriesFlight singleflight.Group
}
type MediaVisibility struct {
+31 -16
View File
@@ -5,7 +5,7 @@ import (
"strings"
"time"
"go.uber.org/zap"
"github.com/google/uuid"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
@@ -129,6 +129,26 @@ func (s *MediaService) GroupedMediaVisibleFiltered(
visibility MediaVisibility,
filters MediaListFilters,
) ([]MediaItem, error) {
value, err := s.groupedMediaVisibleFiltered(ctx, libraryID, visibility, filters)
if err != nil {
return nil, err
}
return value.Items, nil
}
// Each immutable grouping has its own revision. Lightweight sort indices can
// reference that revision without retaining another copy of the full rows.
type groupedMediaCacheValue struct {
Items []MediaItem
Revision string
}
func (s *MediaService) groupedMediaVisibleFiltered(
ctx context.Context,
libraryID string,
visibility MediaVisibility,
filters MediaListFilters,
) (*groupedMediaCacheValue, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
if err != nil {
@@ -143,7 +163,7 @@ func (s *MediaService) GroupedMediaVisibleFiltered(
value, err, _ := s.groupedMediaFlight.Do(itemsCacheKey, func() (any, error) {
if s.cache != nil {
if cachedObj, ok := s.cache.GetObject(itemsCacheKey); ok {
if cached, ok := cachedObj.([]MediaItem); ok {
if cached, ok := cachedObj.(*groupedMediaCacheValue); ok {
return cached, nil
}
}
@@ -154,8 +174,8 @@ func (s *MediaService) GroupedMediaVisibleFiltered(
if err != nil {
return nil, err
}
grouped := groupMediaVersions(items)
if s.cache != nil && len(grouped) > 0 {
grouped := &groupedMediaCacheValue{Items: groupMediaVersions(items), Revision: uuid.NewString()}
if s.cache != nil && len(grouped.Items) > 0 {
s.cache.SetObject(itemsCacheKey, grouped, s.mediaObjectTTL())
}
return grouped, nil
@@ -163,10 +183,10 @@ func (s *MediaService) GroupedMediaVisibleFiltered(
if err != nil {
return nil, err
}
if grouped, ok := value.([]MediaItem); ok {
if grouped, ok := value.(*groupedMediaCacheValue); ok {
return grouped, nil
}
return nil, nil
return &groupedMediaCacheValue{}, nil
}
func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryID string, visibility MediaVisibility, filters MediaListFilters) ([]model.Media, error) {
@@ -180,24 +200,19 @@ func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryI
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
})
// 版本分组的 URL 分页发生在 Go 进程内,响应里的 total 是分组后的数量,
// 不需要数据库再为原始行做一次 COUNT(*)。全量 COUNT 在超大媒体库上
// 会重复扫描整个 library_id 范围,而这里只关心是否存在截断风险。
items, err := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, maxMediaSearchLimit, filter)
// Group before paginating so versions stay together. The search safety limit
// must not truncate a library listing or its reported grouped total.
items, err := s.repo.Media.ListAllByLibrariesFilteredNoCount(ctx, libraryIDs, filter)
if err != nil {
return nil, err
}
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.Int("limit", maxMediaSearchLimit))
}
s.attachLibraryMetadata(ctx, items)
return items, nil
}
// GetMedia returns a single media row.
func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, error) { media, err := s.repo.Media.FindByID(ctx, id)
func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, error) {
media, err := s.repo.Media.FindByID(ctx, id)
if err != nil || media == nil {
return media, err
}
+66
View File
@@ -2,17 +2,71 @@ package service
import (
"context"
"fmt"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
type MediaQualityItem struct {
Issue string `json:"issue"`
Media model.Media `json:"media"`
}
func (s *MediaService) QualityIssues(ctx context.Context, issue string, page, pageSize int) ([]MediaQualityItem, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 50
}
if pageSize > 100 {
pageSize = 100
}
q := s.repo.DB.WithContext(ctx).Model(&model.Media{})
switch issue {
case "missing":
q = q.Where("availability_state = ?", "missing")
case "unmatched":
q = q.Where("scrape_status IN ? OR scrape_status = ''", []string{"pending", "no_match"})
case "poster":
q = q.Where("poster_url IS NULL OR TRIM(poster_url) = ''")
default:
return nil, 0, fmt.Errorf("unknown quality issue: %s", issue)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.Media
if err := q.Order("updated_at DESC, id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil {
return nil, 0, err
}
s.attachLibraryMetadata(ctx, rows)
items := make([]MediaQualityItem, 0, len(rows))
for _, media := range rows {
items = append(items, MediaQualityItem{Issue: issue, Media: media})
}
return items, total, nil
}
type MediaSearchFilter struct {
LibraryID string
YearMin int
YearMax int
Genres []string
}
// SearchMedia performs a simple LIKE search across titles.
func (s *MediaService) SearchMedia(ctx context.Context, query string, limit int) ([]model.Media, error) {
return s.SearchMediaVisible(ctx, query, limit, MediaVisibility{IncludeNSFW: true})
}
func (s *MediaService) SearchMediaVisible(ctx context.Context, query string, limit int, visibility MediaVisibility) ([]model.Media, error) {
return s.SearchMediaVisibleFiltered(ctx, query, limit, visibility, MediaSearchFilter{})
}
func (s *MediaService) SearchMediaVisibleFiltered(ctx context.Context, query string, limit int, visibility MediaVisibility, filter MediaSearchFilter) ([]model.Media, error) {
if limit <= 0 {
limit = 50
} else if limit > maxMediaSearchLimit {
@@ -23,6 +77,10 @@ func (s *MediaService) SearchMediaVisible(ctx context.Context, query string, lim
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
LibraryID: filter.LibraryID,
YearMin: filter.YearMin,
YearMax: filter.YearMax,
Genres: filter.Genres,
})
if err != nil {
return nil, err
@@ -45,6 +103,10 @@ func (s *MediaService) SearchMediaVisibleGrouped(ctx context.Context, query stri
}
func (s *MediaService) SearchMediaVisiblePage(ctx context.Context, query string, page, pageSize int, visibility MediaVisibility) ([]model.Media, int64, error) {
return s.SearchMediaVisiblePageFiltered(ctx, query, page, pageSize, visibility, MediaSearchFilter{})
}
func (s *MediaService) SearchMediaVisiblePageFiltered(ctx context.Context, query string, page, pageSize int, visibility MediaVisibility, filter MediaSearchFilter) ([]model.Media, int64, error) {
if pageSize <= 0 {
pageSize = 50
}
@@ -59,6 +121,10 @@ func (s *MediaService) SearchMediaVisiblePage(ctx context.Context, query string,
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
LibraryID: filter.LibraryID,
YearMin: filter.YearMin,
YearMax: filter.YearMax,
Genres: filter.Genres,
})
if err != nil {
return nil, 0, err
+315 -29
View File
@@ -6,6 +6,7 @@ package service
import (
"context"
"errors"
"strings"
"time"
@@ -43,9 +44,20 @@ type SegmentView struct {
}
// ToSegmentViews 转换库内行为对外视图。
//
// 顺手丢掉明显不可能的区间:库里的历史数据可能是在 end_ms: 0 被误当成「到片尾」
// 的时期写下的(那会把整集变成片头),这些行如果原样下发,播放器仍然会整集弹
// 跳过按钮。只有片尾类允许开放结束点(EndMs == 0),其余要求 EndMs > StartMs。
func ToSegmentViews(rows []model.MediaSegment) []SegmentView {
out := make([]SegmentView, 0, len(rows))
for _, row := range rows {
openEnded := row.Kind == model.SegmentKindCredits || row.Kind == model.SegmentKindPreview
if row.StartMs < 0 {
continue
}
if row.EndMs <= row.StartMs && !(openEnded && row.EndMs <= 0) {
continue
}
out = append(out, SegmentView{Kind: row.Kind, StartMs: row.StartMs, EndMs: row.EndMs})
}
return out
@@ -53,16 +65,36 @@ func ToSegmentViews(rows []model.MediaSegment) []SegmentView {
// MediaSegmentService 负责把外部片头片尾数据补齐到本地并供播放器读取。
type MediaSegmentService struct {
log *zap.Logger
repo *repository.Container
introdb *IntroDBService
// probe 负责异步补齐媒体信息(主要是 STRM 时长)。未注入时只用 TheIntroDB。
log *zap.Logger
repo *repository.Container
// probe 负责异步补齐媒体信息(主要是 STRM 时长)。未注入时只用外部源。
probe *MediaProbeService
// prewarmGap 是预热两次 IntroDB 请求之间的间隔;测试可设为 0。
// prewarmGap 是预热两次请求之间的间隔;测试可设为 0。
prewarmGap time.Duration
now func() time.Time
// providers 是已启用的外部片段源,顺序即注册顺序。
// 由设置项 segment.source 决定实际使用哪一个,见 activeProvider。
providers []SegmentProvider
// imdb 用于给只认 IMDb id 的源(IntroDB)解析外部 id。未注入时该源被跳过。
imdb IMDbIDResolver
}
// IMDbIDResolver 把 TMDb 条目解析成 IMDb id(ttXXXXXXX)。
// 由 TMDbProvider 实现;抽出接口是为了让 service 层不直接依赖配置与 API key。
type IMDbIDResolver interface {
ResolveIMDbID(ctx context.Context, tmdbID int, isMovie bool) (string, error)
}
// 片段数据源设置项。取值形如 off / theintrodb / introdb / todb,
// 与播放器档案里的「自动跳过」是两件独立的事。
const SegmentSourceSettingKey = "segment.source"
// SegmentSourceOff 表示不查询任何外部源,只消费已落库的数据(手动标记等)。
const SegmentSourceOff = "off"
// SegmentSourceOptions 是设置页可选的源标识。
var SegmentSourceOptions = []string{SegmentSourceOff, IntroDBSource, IntroDBAppSource, TODbSource}
// NewMediaSegmentService is the constructor.
func NewMediaSegmentService(log *zap.Logger, repo *repository.Container) *MediaSegmentService {
return &MediaSegmentService{
@@ -81,14 +113,98 @@ func (s *MediaSegmentService) SetPrewarmGap(gap time.Duration) *MediaSegmentServ
return s
}
// SetIntroDB wires the provider. Without it the service only reads cached rows.
// SetIntroDB wires the TheIntroDB provider and registers it as a source.
func (s *MediaSegmentService) SetIntroDB(p *IntroDBService) *MediaSegmentService {
if s != nil {
s.introdb = p
s.registerProvider(p)
}
return s
}
// SetIntroDBApp wires the IntroDB (introdb.app) provider.
func (s *MediaSegmentService) SetIntroDBApp(p *IntroDBAppService) *MediaSegmentService {
if s != nil {
s.registerProvider(p)
}
return s
}
// SetTODb wires the TODb provider.
func (s *MediaSegmentService) SetTODb(p *TODbService) *MediaSegmentService {
if s != nil {
s.registerProvider(p)
}
return s
}
// SetIMDbResolver wires the TMDb-backed IMDb id resolver used by sources that
// only address media by IMDb id.
func (s *MediaSegmentService) SetIMDbResolver(r IMDbIDResolver) *MediaSegmentService {
if s != nil {
s.imdb = r
}
return s
}
// registerProvider adds a provider once, keeping registration order as the
// fallback order.
func (s *MediaSegmentService) registerProvider(p SegmentProvider) {
if s == nil || p == nil {
return
}
for _, existing := range s.providers {
if existing.Source() == p.Source() {
return
}
}
s.providers = append(s.providers, p)
}
// providerBySource returns the registered provider with that source tag.
func (s *MediaSegmentService) providerBySource(source string) SegmentProvider {
for _, p := range s.providers {
if p.Source() == source {
return p
}
}
return nil
}
// activeProvider 选出本次查询要用的源。
//
// 设置项没配置时沿用历史行为(TheIntroDB),避免升级后功能凭空消失;
// 显式配置成 off 才真正停用外部查询。
func (s *MediaSegmentService) activeProvider(ctx context.Context) SegmentProvider {
if s == nil || len(s.providers) == 0 {
return nil
}
source := s.configuredSource(ctx)
if source == "" {
return s.providerBySource(IntroDBSource)
}
if source == SegmentSourceOff {
return nil
}
if p := s.providerBySource(source); p != nil {
return p
}
// 配了一个不认识的源(多半是降级回滚后的残留设置):退回默认源,
// 让功能继续可用,而不是静默失效。
return s.providerBySource(IntroDBSource)
}
// configuredSource reads the operator's source selection.
func (s *MediaSegmentService) configuredSource(ctx context.Context) string {
if s == nil || s.repo == nil || s.repo.Setting == nil {
return ""
}
v, err := s.repo.Setting.Get(ctx, SegmentSourceSettingKey)
if err != nil {
return ""
}
return strings.ToLower(strings.TrimSpace(v))
}
// SetProbe wires the async media probe (duration backfill for STRM). Without it
// open-ended credits still work once duration is known from elsewhere.
func (s *MediaSegmentService) SetProbe(p *MediaProbeService) *MediaSegmentService {
@@ -114,7 +230,7 @@ func (s *MediaSegmentService) ListForPlayback(ctx context.Context, m *model.Medi
// 写库,若在本次写事务还没结束时启动,两个写事务会抢同一把锁(SQLite 下就是
// SQLITE_BUSY,实测能直接把社区库的落库打失败)。
defer s.ensureMediaProbe(ctx, m)
return s.introDBSegments(ctx, m)
return s.communitySegments(ctx, m)
}
// ensureMediaProbe 在还没探过(或上次失败已过冷却期)时起一次异步探测。
@@ -147,10 +263,15 @@ func mediaProbeSettled(row *model.MediaProbe) bool {
return time.Since(row.ProbedAt) < mediaProbeFailureRetry
}
// introDBSegments 读社区库的片段,缓存过期时按调用方的预算抓一次并落库。
// 返回值是按来源优先级合并后的结果(manual > theintrodb > propagated)。
func (s *MediaSegmentService) introDBSegments(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
ledger, err := s.repo.MediaSegment.GetFetch(ctx, m.ID, IntroDBSource)
// communitySegments 读社区库的片段,缓存过期时按调用方的预算抓一次并落库。
// 返回值是按来源优先级合并后的结果(manual > 外部源 > propagated)。
func (s *MediaSegmentService) communitySegments(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
provider := s.activeProvider(ctx)
// 没选任何外部源(off):只读本地已有数据,绝不发起外网请求。
if provider == nil {
return s.mergeForPlayback(context.WithoutCancel(ctx), m.ID)
}
ledger, err := s.repo.MediaSegment.GetFetch(ctx, m.ID, provider.Source())
if err != nil {
// 调用方预算耗尽时仍读本地缓存,绝不能让播放接口报错。
if ctx.Err() != nil {
@@ -159,7 +280,7 @@ func (s *MediaSegmentService) introDBSegments(ctx context.Context, m *model.Medi
return nil, err
}
if ledger == nil || !ledgerFresh(ledger) {
if _, _, err := s.refresh(ctx, m); err != nil {
if _, _, err := s.refreshWithProvider(ctx, m, provider); err != nil {
// 社区库不可达或返回异常:沿用已有缓存,不影响播放;也不写负缓存。
logIntroDBFailure(s.log, 0, err)
}
@@ -178,13 +299,22 @@ func (s *MediaSegmentService) mergeForPlayback(ctx context.Context, mediaID stri
}
// segmentSourcePriority 数值越大越优先。未知来源视为最低,避免挡住已知源。
//
// 顺序体现用户意图的强度:
//
// manual 用户在当前集显式标记
// propagated 用户在同季其它集标记后继承来的值(仍然是人给的,优先于社区库)
// 外部社区库 自动抓取,最容易被覆盖
//
// 外部社区库之间刻意同级:用户一次只会启用其中一个,历史数据可能来自另一个,
// 同级时按区间起点排序展示即可,不必让旧源压住新源。
func segmentSourcePriority(source string) int {
switch source {
case SegmentSourceManual:
return 3
case IntroDBSource:
return 2
case SegmentSourcePropagated:
return 2
case IntroDBSource, IntroDBAppSource, TODbSource:
return 1
default:
return 0
@@ -224,16 +354,17 @@ func (s *MediaSegmentService) debug(message, mediaID string, err error) {
s.log.Debug(message, zap.String("media_id", mediaID), zap.Error(err))
}
// refresh 向提供方查询并落库,返回 (rows, 是否真的发起过查询, error)。
// refreshWithProvider 向指定来源查询并落库,返回 (rows, 是否真的发起过查询, error)。
//
// attempted=false 表示这部媒体缺少可查询的外部 ID(最常见的原因是还没刮削,
// 剧集也还没关联到 Series),此时刻意不写负缓存:等元数据补齐后下次播放就能查到。
func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]model.MediaSegment, bool, error) {
if s.introdb == nil {
// 剧集也还没关联到 Series),或者该源需要的 id 类型拿不到(IntroDB 要 IMDb),
// 此时刻意不写负缓存:等元数据补齐后下次播放就能查到。
func (s *MediaSegmentService) refreshWithProvider(ctx context.Context, m *model.Media, provider SegmentProvider) ([]model.MediaSegment, bool, error) {
if s == nil || provider == nil || m == nil {
return nil, false, nil
}
tmdbID, season, episode := s.queryIDs(ctx, m)
if tmdbID <= 0 {
query, ok := s.segmentQuery(ctx, m, provider)
if !ok {
return nil, false, nil
}
// 用脱离请求的 context:客户端可能在抓取完成前就离开了播放页,但结果仍然
@@ -256,10 +387,11 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), budget)
defer cancel()
spans, err := s.introdb.Fetch(fetchCtx, tmdbID, season, episode)
spans, err := provider.FetchSegments(fetchCtx, query)
if err != nil {
return nil, true, err
}
source := provider.Source()
rows := make([]model.MediaSegment, 0, len(spans))
for _, span := range spans {
rows = append(rows, model.MediaSegment{
@@ -268,15 +400,15 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
Kind: span.Kind,
StartMs: span.StartMs,
EndMs: span.EndMs,
Source: IntroDBSource,
Source: source,
})
}
if err := s.repo.MediaSegment.ReplaceForMedia(fetchCtx, m.ID, IntroDBSource, rows); err != nil {
if err := s.repo.MediaSegment.ReplaceForMedia(fetchCtx, m.ID, source, rows); err != nil {
return nil, true, err
}
if err := s.repo.MediaSegment.UpsertFetch(fetchCtx, &model.MediaSegmentFetch{
MediaID: m.ID,
Source: IntroDBSource,
Source: source,
FetchedAt: time.Now(),
Found: len(rows) > 0,
}); err != nil {
@@ -286,6 +418,38 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
return rows, true, nil
}
// refresh 保留旧签名(预热任务与测试使用),走当前选中的源。
func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]model.MediaSegment, bool, error) {
return s.refreshWithProvider(ctx, m, s.activeProvider(ctx))
}
// segmentQuery 组装该源需要的身份参数。返回 false 表示这个源缺少必要 ID,
// 本次不必发请求。
func (s *MediaSegmentService) segmentQuery(ctx context.Context, m *model.Media, provider SegmentProvider) (SegmentQuery, bool) {
tmdbID, season, episode := s.queryIDs(ctx, m)
isMovie := season <= 0 && episode <= 0
q := SegmentQuery{TMDbID: tmdbID, Season: season, Episode: episode, IsMovie: isMovie}
switch provider.Source() {
case IntroDBAppSource:
// IntroDB 只认 IMDb id,必须经 TMDb external_ids 解析。
if s.imdb == nil || tmdbID <= 0 {
return q, false
}
imdbID, err := s.imdb.ResolveIMDbID(ctx, tmdbID, isMovie)
if err != nil || imdbID == "" {
// 解析不出 IMDb id 不是错误:这部片可能本来就没有,或 TMDb 不可达。
// 返回 false 让本次跳过,但不写负缓存,下次还有机会。
s.debug("resolve imdb id failed", m.ID, err)
return q, false
}
q.IMDbID = imdbID
return q, true
default:
return q, tmdbID > 0
}
}
// propagateIntroToSeason 把本集 IntroDB 命中的 intro 复制到同季还没有
// theintrodb intro 的兄弟集。片尾/预告不传播:各集时长与片尾位置经常不同。
func (s *MediaSegmentService) propagateIntroToSeason(ctx context.Context, m *model.Media, rows []model.MediaSegment) {
@@ -347,9 +511,131 @@ func hasSegmentKind(rows []model.MediaSegment, kind string) bool {
return false
}
// Prewarm 批量向 TheIntroDB 补齐过期/缺失账本的可查询媒体。返回实际发起过查询的数量。
// ManualMarkRequest 是「标记片头/片尾」的入参。
//
// PositionMs 是用户点击时所在的播放位置:标记片头时它是片头结束点,
// 标记片尾时它是片尾开始点。
type ManualMarkRequest struct {
Kind string
PositionMs int64
// Clear 为 true 表示清除该类型的标记,PositionMs 被忽略。
Clear bool
}
// ErrInvalidManualMark 表示请求本身不合法(类型的取值或位置越界)。
var ErrInvalidManualMark = errors.New("invalid manual skip mark")
// SetManualMark 写入或清除一条手动标记,并按季传播片头/片尾。
//
// 手动标记的优先级最高(见 segmentSourcePriority),所以用户标过一次之后,
// 外部源即使给出不同位置也不会覆盖它。清除时只删 manual 来源的行,外部数据
// 会自然回落到优先队列里。
func (s *MediaSegmentService) SetManualMark(ctx context.Context, m *model.Media, req ManualMarkRequest) error {
if s == nil || s.repo == nil || m == nil || m.ID == "" {
return ErrInvalidManualMark
}
kind := normalizeManualMarkKind(req.Kind)
if kind == "" {
return ErrInvalidManualMark
}
if !req.Clear && req.PositionMs < 0 {
return ErrInvalidManualMark
}
rows := []model.MediaSegment{}
if !req.Clear {
if req.PositionMs <= 0 {
// 位置 0 的标记没有意义:片头在 0 秒结束等于没有片头。
return ErrInvalidManualMark
}
rows = append(rows, model.MediaSegment{
MediaID: m.ID,
SeriesID: m.SeriesID,
Kind: kind,
StartMs: manualMarkStart(kind),
EndMs: req.PositionMs,
Source: SegmentSourceManual,
})
}
if err := s.repo.MediaSegment.ReplaceForMediaSourceKind(ctx, m.ID, SegmentSourceManual, kind, rows); err != nil {
return err
}
if len(rows) > 0 {
s.propagateManualMarkToSeason(ctx, m, rows)
}
return nil
}
// normalizeManualMarkKind 把外部传入的类型收敛到库内 kind。
// 只接受片头与片尾:回顾/预告没有对应的用户操作入口。
func normalizeManualMarkKind(kind string) string {
switch strings.ToLower(strings.TrimSpace(kind)) {
case model.SegmentKindIntro:
return model.SegmentKindIntro
case model.SegmentKindCredits:
return model.SegmentKindCredits
default:
return ""
}
}
// manualMarkStart 给出该类型区间的起点:片头从 0 开始,片尾的开始点就是用户
// 标记的位置(所以这里填 0,真正的起点在 EndMs 里由调用方传入)。
func manualMarkStart(kind string) int64 { return 0 }
// propagateManualMarkToSeason 把手动标记复制到同季还没有「显式手动标记」的兄弟集。
//
// 同一季的 OP/ED 通常一致,标一集即可覆盖整季——这是报告里最实用的行为。
// 关键约束:传播值必须写成 SegmentSourcePropagated 而不是 manual,这样才能区分
// 「用户显式标过的集」与「继承来的值」。否则先标 A 集会让 B 集看起来也是手动标
// 过的,之后用户再标 B 集时反而无法覆盖它(或反过来,B 集的传播值挡住 A 集的
// 新标记),两种顺序都会得出错误结果。
func (s *MediaSegmentService) propagateManualMarkToSeason(ctx context.Context, m *model.Media, rows []model.MediaSegment) {
if s == nil || s.repo == nil || m == nil || m.SeasonNum <= 0 || len(rows) == 0 {
return
}
siblings, err := s.repo.Media.ListSeasonSiblings(ctx, m)
if err != nil {
s.debug("list season siblings for manual mark failed", m.ID, err)
return
}
for i := range siblings {
sib := &siblings[i]
// 用户显式标过的集保持原样;继承值可以被新的显式标记覆盖。
explicit, err := s.repo.MediaSegment.ListByMediaSource(ctx, sib.ID, SegmentSourceManual)
if err != nil {
s.debug("list sibling manual segments failed", sib.ID, err)
continue
}
if hasSegmentKind(explicit, rows[0].Kind) {
continue
}
copied := make([]model.MediaSegment, 0, len(rows))
for _, row := range rows {
copied = append(copied, model.MediaSegment{
MediaID: sib.ID,
SeriesID: sib.SeriesID,
Kind: row.Kind,
StartMs: row.StartMs,
EndMs: row.EndMs,
Source: SegmentSourcePropagated,
})
}
if err := s.repo.MediaSegment.ReplaceForMediaSourceKind(ctx, sib.ID, SegmentSourcePropagated, rows[0].Kind, copied); err != nil {
s.debug("propagate manual mark failed", sib.ID, err)
}
}
}
// Prewarm 批量补齐过期/缺失账本的可查询媒体。返回实际发起过查询的数量。
//
// 只预热当前启用的源:用户把源切成 off 时不该继续打外网,切换源之后旧源的
// 账本也不再需要刷新。
func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, error) {
if s == nil || s.repo == nil || s.introdb == nil {
if s == nil || s.repo == nil || len(s.providers) == 0 {
return 0, nil
}
provider := s.activeProvider(ctx)
if provider == nil {
return 0, nil
}
if limit <= 0 {
@@ -360,7 +646,7 @@ func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, erro
now = s.now()
}
candidates, err := s.repo.MediaSegment.ListPrewarmCandidates(
ctx, IntroDBSource, now.Add(-segmentMissingTTL), now.Add(-segmentFoundTTL), limit,
ctx, provider.Source(), now.Add(-segmentMissingTTL), now.Add(-segmentFoundTTL), limit,
)
if err != nil {
return 0, err
@@ -371,7 +657,7 @@ func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, erro
return attempted, err
}
m := &candidates[i]
_, did, err := s.refresh(ctx, m)
_, did, err := s.refreshWithProvider(ctx, m, provider)
if err != nil {
logIntroDBFailure(s.log, m.TMDbID, err)
}
+7 -4
View File
@@ -385,7 +385,9 @@ func TestListForPlaybackRespectsCallerDeadline(t *testing.T) {
}
}
func TestPreferSegmentsBySourcePrefersManualThenIntroDBThenPropagated(t *testing.T) {
// 优先级体现「用户意图强度」:manual > propagated > 社区库。
// 传播值来自用户在同季其它集的手动标记,是人给的,必须压过自动抓取的数据。
func TestPreferSegmentsBySourcePrefersManualThenPropagatedThenIntroDB(t *testing.T) {
rows := []model.MediaSegment{
{Kind: model.SegmentKindIntro, StartMs: 1, EndMs: 2, Source: SegmentSourcePropagated},
{Kind: model.SegmentKindIntro, StartMs: 10, EndMs: 20, Source: IntroDBSource},
@@ -395,13 +397,14 @@ func TestPreferSegmentsBySourcePrefersManualThenIntroDBThenPropagated(t *testing
}
got := preferSegmentsBySource(rows)
if len(got) != 2 {
t.Fatalf("got %#v, want manual intro + introdb credits", got)
t.Fatalf("got %#v, want manual intro + propagated credits", got)
}
if got[0].Source != SegmentSourceManual || got[0].StartMs != 100 {
t.Fatalf("intro = %#v, want manual", got[0])
}
if got[1].Source != IntroDBSource || got[1].StartMs != 2000 {
t.Fatalf("credits = %#v, want theintrodb", got[1])
// 片尾没有 manual,propagated 应当压过社区库。
if got[1].Source != SegmentSourcePropagated || got[1].StartMs != 1000 {
t.Fatalf("credits = %#v, want propagated", got[1])
}
}
+77
View File
@@ -0,0 +1,77 @@
package service
import (
"context"
"fmt"
"sort"
"time"
)
// ListMediaVisibleSortedPage reuses grouped rows and a small position index.
// Only the requested page is copied; pagination does not sort the full library
// again or retain a second full-size MediaItem slice for every sort order.
func (s *MediaService) ListMediaVisibleSortedPage(
ctx context.Context,
libraryID string,
page, pageSize int,
visibility MediaVisibility,
filters MediaListFilters,
spec MediaSortSpec,
history map[string]time.Time,
) ([]MediaItem, int64, error) {
grouped, err := s.groupedMediaVisibleFiltered(ctx, libraryID, visibility, filters)
if err != nil {
return nil, 0, err
}
spec = NormalizeMediaSort(spec.Field, spec.Order)
indices := s.sortedMediaIndices(grouped, spec, history)
page, pageSize = normalizeGroupedMediaPage(page, pageSize)
total := len(grouped.Items)
// Division avoids overflowing (page-1)*pageSize on an excessive page value.
if total == 0 || page-1 > (total-1)/pageSize {
return []MediaItem{}, int64(total), nil
}
start := (page - 1) * pageSize
end := min(start+pageSize, total)
items := make([]MediaItem, 0, end-start)
for _, index := range indices[start:end] {
items = append(items, grouped.Items[index])
}
return items, int64(total), nil
}
func (s *MediaService) sortedMediaIndices(grouped *groupedMediaCacheValue, spec MediaSortSpec, history map[string]time.Time) []int {
// Playback history changes independently of the media cache and is personal.
// Keep this explicit sort live rather than caching another user's history.
if s.cache == nil || spec.Field == "last_played" || len(grouped.Items) == 0 {
return sortMediaIndices(grouped.Items, spec, history)
}
key := fmt.Sprintf("media:obj:sort:%s:%s:%s", grouped.Revision, spec.Field, spec.Order)
value, _, _ := s.mediaSortFlight.Do(key, func() (any, error) {
if cached, ok := s.cache.GetObject(key); ok {
if indices, ok := cached.([]int); ok {
return indices, nil
}
}
indices := sortMediaIndices(grouped.Items, spec, history)
s.cache.SetObject(key, indices, s.mediaObjectTTL())
return indices, nil
})
return value.([]int)
}
func sortMediaIndices(items []MediaItem, spec MediaSortSpec, history map[string]time.Time) []int {
indices := make([]int, len(items))
for i := range indices {
indices[i] = i
}
sort.SliceStable(indices, func(i, j int) bool {
a, b := items[indices[i]], items[indices[j]]
cmp := compareMediaByField(a, b, spec, history)
if cmp == 0 {
cmp = compareID(a.ID, b.ID)
}
return cmp < 0
})
return indices
}
+141
View File
@@ -0,0 +1,141 @@
package service
import (
"fmt"
"math"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func TestSortedMediaPageIncludesRowsBeyondSearchLimit(t *testing.T) {
db := newServiceTestDB(t)
repos := repository.New(db)
lib := model.Library{Name: "Large", Path: "/media/large", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
// This must cross the historical 50,000-row boundary: a smaller fixture
// cannot detect truncation before grouping, counting, and sorting.
now := time.Now()
for start := 0; start <= maxMediaSearchLimit; start += 500 {
var rows []model.Media
for i := start; i < min(start+500, maxMediaSearchLimit+1); i++ {
rows = append(rows, model.Media{
Base: model.Base{ID: fmt.Sprintf("large-%05d", i), CreatedAt: now, UpdatedAt: now},
LibraryID: lib.ID, Path: fmt.Sprintf("/media/large/%05d.mkv", i),
Title: fmt.Sprintf("Movie %05d", i), TMDbID: i + 1,
})
}
if err := db.CreateInBatches(&rows, 100).Error; err != nil {
t.Fatal(err)
}
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
items, total, err := svc.ListMediaVisibleSortedPage(t.Context(), lib.ID, maxMediaSearchLimit/100+1, 100,
MediaVisibility{IncludeNSFW: true}, MediaListFilters{}, MediaSortSpec{Field: "title", Order: "asc"}, nil)
if err != nil {
t.Fatal(err)
}
if total != int64(maxMediaSearchLimit+1) || len(items) != 1 || items[0].ID != "large-50000" {
t.Fatalf("total=%d, page length=%d, want 50001 rows and final item large-50000", total, len(items))
}
}
func TestSortedMediaPagesReuseIndicesAndRefreshAfterInvalidation(t *testing.T) {
db := newServiceTestDB(t)
repos := repository.New(db)
lib := model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
rows := []model.Media{
{Base: model.Base{ID: "alpha"}, LibraryID: lib.ID, Path: "/media/movies/a.mkv", Title: "Alpha", TMDbID: 1, SizeBytes: 100},
{Base: model.Base{ID: "alpha-alt"}, LibraryID: lib.ID, Path: "/media/movies/a-alt.mkv", Title: "Alpha", TMDbID: 1, SizeBytes: 50},
{Base: model.Base{ID: "beta"}, LibraryID: lib.ID, Path: "/media/movies/b.mkv", Title: "Beta", TMDbID: 2},
{Base: model.Base{ID: "hidden"}, LibraryID: lib.ID, Path: "/media/movies/hidden.mkv", Title: "Hidden", TMDbID: 3, NSFW: true},
}
if err := 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: false}
spec := MediaSortSpec{Field: "title", Order: "asc"}
first, total, err := svc.ListMediaVisibleSortedPage(t.Context(), lib.ID, 1, 1, visibility, MediaListFilters{}, spec, nil)
if err != nil || total != 2 || len(first) != 1 || first[0].ID != "alpha" || len(first[0].Versions) != 2 {
t.Fatalf("first=%#v, total=%d, error=%v", first, total, err)
}
grouped, err := svc.groupedMediaVisibleFiltered(t.Context(), lib.ID, visibility, MediaListFilters{})
if err != nil {
t.Fatal(err)
}
index := svc.sortedMediaIndices(grouped, spec, nil)
second, _, err := svc.ListMediaVisibleSortedPage(t.Context(), lib.ID, 2, 1, visibility, MediaListFilters{}, spec, nil)
if err != nil || len(second) != 1 || second[0].ID != "beta" {
t.Fatalf("second=%#v, error=%v", second, err)
}
cachedIndex := svc.sortedMediaIndices(grouped, spec, nil)
if &index[0] != &cachedIndex[0] {
t.Fatal("pagination rebuilt the sorted position index")
}
// The returned page is independently writable without corrupting cached rows.
first[0].Title = "Modified by caller"
if err := db.Model(&model.Media{}).Where("id = ?", "beta").Update("title", "Aardvark").Error; err != nil {
t.Fatal(err)
}
svc.invalidateMediaCache(t.Context())
fresh, _, err := svc.ListMediaVisibleSortedPage(t.Context(), lib.ID, 1, 1, visibility, MediaListFilters{}, spec, nil)
if err != nil || len(fresh) != 1 || fresh[0].ID != "beta" {
t.Fatalf("updated page=%#v, error=%v", fresh, err)
}
empty, _, err := svc.ListMediaVisibleSortedPage(t.Context(), lib.ID, math.MaxInt, 100, visibility, MediaListFilters{}, spec, nil)
if err != nil || empty == nil || len(empty) != 0 {
t.Fatalf("out-of-range page=%#v, error=%v", empty, err)
}
}
func TestMediaSortIndexDoesNotCachePersonalPlaybackHistory(t *testing.T) {
svc := NewMediaService(&config.Config{}, zap.NewNop(), nil).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
grouped := &groupedMediaCacheValue{Revision: "history-test", Items: []MediaItem{
{Media: model.Media{Base: model.Base{ID: "a"}}},
{Media: model.Media{Base: model.Base{ID: "b"}}},
}}
spec := MediaSortSpec{Field: "last_played", Order: "desc"}
first := svc.sortedMediaIndices(grouped, spec, map[string]time.Time{"a": time.Now()})
second := svc.sortedMediaIndices(grouped, spec, map[string]time.Time{"b": time.Now()})
if first[0] != 0 || second[0] != 1 {
t.Fatalf("history sorts = %v, %v", first, second)
}
}
func BenchmarkMediaSortIndex(b *testing.B) {
items := make([]MediaItem, 10000)
for i := range items {
items[i].ID = fmt.Sprint(i)
items[i].Title = fmt.Sprintf("Movie %05d", len(items)-i)
}
spec := MediaSortSpec{Field: "title", Order: "asc"}
b.Run("sort-every-page", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
SortMediaItems(items, spec.Field, spec.Order, nil)
}
})
b.Run("cached-position-index", func(b *testing.B) {
svc := NewMediaService(&config.Config{}, zap.NewNop(), nil).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
grouped := &groupedMediaCacheValue{Items: items, Revision: "benchmark"}
svc.sortedMediaIndices(grouped, spec, nil)
b.ReportAllocs()
for b.Loop() {
svc.sortedMediaIndices(grouped, spec, nil)
}
})
}
@@ -0,0 +1,649 @@
package reader
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// 远端音轨的本地落盘缓存(媒体代理 /api/reader/media 使用)。
//
// 背景:听书源的音频直链签在腾讯音乐 CDN 上,跨境链路会间歇性返回 401
// 或直接 RST 连接。旧行为是每次请求(首播、拖动进度、播放器重试)都重新
// 回源,一次抖动就把错误交给播放器。这里按「书 + 媒体对象」把整段音频
// 落盘(丢掉 ?vkey=... 这类会轮换的签名参数),命中后由 http.ServeContent
// 按 Range 下发:首播成功之后不再依赖 CDN,拖动进度也是本地读取。
const (
audioMediaDirName = "reader-audio"
audioMediaTTL = 7 * 24 * time.Hour
// audioMediaMaxBytes 音频缓存的总容量上限。超限时按 mtime 淘汰最旧的。
//
// 这个池不参与 cache_cleanup 的全局总量统计(hls/images/library-covers
// 三池),而是在每次下载成功后自行淘汰,见 pruneAudioMediaCache。
audioMediaMaxBytes = 1 << 30 // 1GiB
audioMediaPrunePeriod = 10 * time.Minute
audioMediaPruneKeepPct = 90
// audioPartialSuffix 未下完的音轨分片后缀。
//
// 分片跨请求保留:首播失败后用户再点一次、或播放器重连,都能用 Range 接着下,
// 不必从第 0 字节重来。分片名带 .part 后缀,不会被 OpenCachedRemoteAudio 当成
// 完整音轨下发。
audioPartialSuffix = ".part"
// audioMediaObjectMaxBytes 单个音频对象的落盘上限。有声书一章约 3–5MB,
// 给到 64MB 足够容纳长章节,也能挡住误当成音频的超大对象。
audioMediaObjectMaxBytes = 64 << 20
// audioDownloadRetryDelay 断点续传重试的退避基数,实际等待为 (attempt-1) 倍。
audioDownloadRetryDelay = 500 * time.Millisecond
)
var (
audioMediaPruneMu sync.Mutex
audioMediaPrunedAt time.Time
)
// audioMediaDir 音频缓存根目录(与转码缓存同一套目录约定)。
func (s *ReaderService) audioMediaDir() string {
if s == nil || s.cfg == nil {
return ""
}
base := strings.TrimSpace(s.cfg.Cache.CacheDir)
if base == "" {
if dataDir := strings.TrimSpace(s.cfg.App.DataDir); dataDir != "" {
base = filepath.Join(dataDir, "cache")
}
}
if base == "" {
return ""
}
return filepath.Join(base, audioMediaDirName)
}
// audioMediaVolatileParams 是换票时会变、但与「这是哪段音频」无关的 query 参数。
// 缓存键必须忽略它们,否则每次换票都打不中缓存。
//
// 不在这张表里的参数一律保留:有些书源的直链形如
// https://host/audio?id=12345,路径固定、靠 query 区分章节,把 query 整个丢掉
// 会让第二集命中第一集的缓存,直接播错集。
var audioMediaVolatileParams = map[string]bool{
"vkey": true, "uid": true, "ct": true, "t": true, "ts": true,
"token": true, "sign": true, "signature": true, "sig": true,
"expires": true, "expire": true, "expiry": true, "auth": true,
"nonce": true, "rand": true, "rnd": true, "r": true, "guid": true,
"ftime": true,
}
// audioMediaCacheKey 缓存键:书 + 媒体对象身份。
//
// 身份 = scheme://host/path + 非签名类 query。签名/时效参数(vkey、token、
// 时间戳等)被剔除,保证换票后仍命中同一条缓存;其余参数保留,避免
// 「路径相同、只靠 query 区分」的书源串集。
func audioMediaCacheKey(bookID, mediaURL string) string {
u, err := url.Parse(strings.TrimSpace(mediaURL))
if err != nil || u.Host == "" {
return ""
}
ident := strings.ToLower(u.Scheme) + "://" + strings.ToLower(u.Host) + u.EscapedPath()
if u.RawQuery != "" {
if q, parseErr := url.ParseQuery(u.RawQuery); parseErr == nil {
var kept []string
for k, vs := range q {
if audioMediaVolatileParams[strings.ToLower(k)] {
continue
}
for _, v := range vs {
kept = append(kept, k+"="+v)
}
}
if len(kept) > 0 {
sort.Strings(kept)
ident += "?" + strings.Join(kept, "&")
}
}
}
sum := sha256.Sum256([]byte(bookID + "|" + ident))
return hex.EncodeToString(sum[:16])
}
// RemoteAudioCachePath 返回该音轨的缓存文件路径;无法缓存时返回空串。
func (s *ReaderService) RemoteAudioCachePath(bookID, mediaURL string) string {
key := audioMediaCacheKey(bookID, mediaURL)
if key == "" {
return ""
}
dir := s.audioMediaDir()
if dir == "" {
return ""
}
return filepath.Join(dir, key[:2], key+".audio")
}
// IsAudioMediaURL 判断媒体代理这次要拉的是不是音轨。
//
// 后缀优先:音频书籍里也会出现封面/插图(.jpg)或 HLS 播放列表(.m3u8),
// 这些不能当成音轨整段落盘。只有后缀为空(签名直链常见形态)时才退回按
// 书籍类型判断。
func IsAudioMediaURL(rawURL string, bookType int) bool {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return bookType == 1
}
ext := strings.ToLower(filepath.Ext(u.Path))
if ext == "" {
return bookType == 1
}
return audioExtensions[ext]
}
// HasCachedRemoteAudio 判断该音轨是否已有可用缓存(会顺带刷新 mtime)。
func (s *ReaderService) HasCachedRemoteAudio(bookID, mediaURL string) bool {
f, _, ok := s.OpenCachedRemoteAudio(bookID, mediaURL)
if ok {
_ = f.Close()
}
return ok
}
// HasPartialRemoteAudio 是否有可续传的未完成分片(.part)。
//
// 调用方据此决定首播策略:有分片说明上一次已经下到一半(用户点过重试、
// 或播放器自己重连过),此时「下载完再下发」能透明续上剩余字节;
// 没有分片才是真正的冷启动,边下边发换更低的 TTFB。
func (s *ReaderService) HasPartialRemoteAudio(bookID, mediaURL string) bool {
path := s.RemoteAudioCachePath(bookID, mediaURL)
if path == "" {
return false
}
info, err := os.Stat(path + audioPartialSuffix)
if err != nil || !info.Mode().IsRegular() || info.Size() == 0 {
return false
}
return info.Size() <= audioMediaObjectMaxBytes
}
// OpenCachedRemoteAudio 打开已缓存的音轨(命中返回打开的文件,调用方负责关闭)。
// 过期的缓存顺手删掉,未过期则刷新 mtime 供 LRU 淘汰。
func (s *ReaderService) OpenCachedRemoteAudio(bookID, mediaURL string) (*os.File, os.FileInfo, bool) {
path := s.RemoteAudioCachePath(bookID, mediaURL)
if path == "" {
return nil, nil, false
}
f, err := os.Open(path) // #nosec G304 -- 路径由服务端哈希生成
if err != nil {
return nil, nil, false
}
info, err := f.Stat()
if err != nil || info.IsDir() || info.Size() == 0 {
_ = f.Close()
return nil, nil, false
}
if info.ModTime().Add(audioMediaTTL).Before(time.Now()) {
_ = f.Close()
_ = os.Remove(path)
return nil, nil, false
}
now := time.Now()
_ = os.Chtimes(path, now, now)
return f, info, true
}
// errAudioObjectTooLarge 上游对象超过单章缓存上限(不是可重试的错误)。
var errAudioObjectTooLarge = errors.New("音源超过缓存上限")
// NoRetryError 标记「换着花样再试也不会有进展」的失败(例如上游正成批拒绝
// 签名票、书源根本给不出新票)。带断点续传的下载器收到它会立刻停止,并把
// Err 原样交给调用方——继续重试只会让播放器多等十几秒,还会让对方更坚定地
// 拒绝我们。
type NoRetryError struct{ Err error }
func (e *NoRetryError) Error() string { return e.Err.Error() }
func (e *NoRetryError) Unwrap() error { return e.Err }
// AudioRangeFetcher 取一次上游音轨响应。
//
// rangeHeader 为空表示整段请求;否则形如 "bytes=N-",用于从断点续传。
// head 是调用方已经嗅探读过、必须补写回文件的前缀(只有整段请求才有)。
// 调用方负责在内部完成换票;这里只看状态码和字节。
type AudioRangeFetcher func(rangeHeader string) (resp *http.Response, head []byte, err error)
// AudioStreamStart 首播直通开始的回调:调用方在这里写响应头与状态码。
// total 是上游声明的总长度(分块传输时为 -1),contentType 是上游给的类型。
type AudioStreamStart func(total int64, contentType string)
// errAudioStreamCannotRestart 直通模式下遇到必须「从第 0 字节重来」的情况。
//
// 直通时客户端已经收到了前缀字节,不可能让它把已收到的丢掉重听,所以这类
// 分支只能中止这次响应(让客户端判成不完整),并把已下载的分片留给下次续传。
var errAudioStreamCannotRestart = errors.New("直通中无法从零重新下载")
// audioSinkError 标记「写客户端失败」(通常是播放器关掉了连接)。
// 与写盘失败区分开:写盘失败说明本地有问题,写客户端失败只需停止下载。
type audioSinkError struct{ err error }
func (e *audioSinkError) Error() string { return "写入客户端失败: " + e.err.Error() }
func (e *audioSinkError) Unwrap() error { return e.err }
// DownloadRemoteAudio 带断点续传地把整段音轨写进缓存,返回最终文件路径。
//
// 为什么需要续传:腾讯 CDN 的跨境边缘常在传输中途 RST,旧实现每次重试都从
// 第 0 字节重下,一章 3MB 白下几次就把首播拖到几十秒。这里把已写入的字节留在
// <cache>.part 里,下一次用 Range: bytes=N- 续上;分片跨请求保留,用户点重试
// 或播放器重连都能接着下。
//
// 上游忽略 Range 直接回 200、或分片区间对不上时,丢弃分片从头重来。
// 下载过程中的失败不会留下「看起来完整」的缓存文件:只有字节数与上游声明的
// 总长一致才会原子改名到正式路径。
func (s *ReaderService) DownloadRemoteAudio(
ctx context.Context,
bookID, mediaURL string,
attempts int,
fetch AudioRangeFetcher,
) (string, error) {
return s.downloadRemoteAudio(ctx, bookID, mediaURL, attempts, fetch, nil, nil)
}
// DownloadRemoteAudioStreaming 首播直通:一边落盘一边把字节写给 sink。
//
// 与 DownloadRemoteAudio 的区别只有一处——不再等整段下完才返回,第一个字节
// 到达就通过 onStart 写好响应头并开始外发,慢链路上的首播不必先等 3–5MB。
//
// 代价是失败语义变严:客户端已经收到前缀字节,任何「必须从 0 重来」的情况
// (上游忽略 Range 回 200、分片区间对不上、416)都只能中止这次响应,
// 不能像普通模式那样静默重下。已写入的分片仍然保留,下次可续传。
func (s *ReaderService) DownloadRemoteAudioStreaming(
ctx context.Context,
bookID, mediaURL string,
attempts int,
fetch AudioRangeFetcher,
onStart AudioStreamStart,
sink io.Writer,
) (string, error) {
if sink == nil {
return "", errors.New("直通下载未提供输出目标")
}
return s.downloadRemoteAudio(ctx, bookID, mediaURL, attempts, fetch, onStart, sink)
}
// downloadRemoteAudio 是两种下载模式的共同实现。sink 非空即首播直通模式。
func (s *ReaderService) downloadRemoteAudio(
ctx context.Context,
bookID, mediaURL string,
attempts int,
fetch AudioRangeFetcher,
onStart AudioStreamStart,
sink io.Writer,
) (string, error) {
path := s.RemoteAudioCachePath(bookID, mediaURL)
if path == "" {
return "", errors.New("音频缓存目录未配置")
}
if fetch == nil {
return "", errors.New("音频下载器未配置")
}
if attempts <= 0 {
attempts = 1
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return "", fmt.Errorf("创建音频缓存目录失败: %w", err)
}
partPath := path + audioPartialSuffix
var offset int64
if info, statErr := os.Stat(partPath); statErr == nil && info.Mode().IsRegular() {
offset = info.Size()
// 分片太旧或超过上限:续传没有意义,直接丢掉重下。
if offset > audioMediaObjectMaxBytes || info.ModTime().Add(audioMediaTTL).Before(time.Now()) {
_ = os.Remove(partPath)
offset = 0
}
}
file, err := os.OpenFile(partPath, os.O_CREATE|os.O_WRONLY, 0o640)
if err != nil {
return "", fmt.Errorf("创建音频缓存文件失败: %w", err)
}
// onStart 只应触发一次:直通模式下响应头只能写一次。
started := false
var lastErr error
for attempt := 1; attempt <= attempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
_ = file.Close()
return "", ctx.Err()
case <-time.After(audioDownloadRetryDelay * time.Duration(attempt-1)):
}
}
rangeHeader := ""
if offset > 0 {
rangeHeader = fmt.Sprintf("bytes=%d-", offset)
}
resp, head, fetchErr := fetch(rangeHeader)
if fetchErr != nil {
var noRetry *NoRetryError
if errors.As(fetchErr, &noRetry) {
_ = file.Close()
// 已经写下的分片留着:上游恢复后用户再点一次能接着续。
if offset == 0 {
_ = os.Remove(partPath)
}
return "", noRetry.Err
}
lastErr = fetchErr
continue
}
if resp == nil {
lastErr = errors.New("音源响应为空")
continue
}
expected := int64(-1)
switch resp.StatusCode {
case http.StatusPartialContent:
start, total, ok := parseContentRange(resp.Header.Get("Content-Range"))
if !ok || offset == 0 || start != offset {
drainAndClose(resp.Body)
if sink != nil && offset > 0 {
// 已经发给客户端 offset 字节:重来会让客户端收到重复内容。
// 分片保持原样,下次请求从这里续。
_ = file.Close()
return "", errAudioStreamCannotRestart
}
offset = 0
_ = file.Truncate(0)
lastErr = fmt.Errorf("音源返回的分片区间不可用(%q)", resp.Header.Get("Content-Range"))
continue
}
expected = total
case http.StatusOK:
// 上游忽略了 Range(或者本来就是整段请求):只能从第 0 字节写。
if offset > 0 {
drainAndClose(resp.Body)
if sink != nil {
// 同上:已发出去的字节不能撤回,分片留着供下次续传。
_ = file.Close()
return "", errAudioStreamCannotRestart
}
offset = 0
if truncErr := file.Truncate(0); truncErr != nil {
lastErr = fmt.Errorf("重置音频缓存失败: %w", truncErr)
continue
}
}
expected = resp.ContentLength
case http.StatusRequestedRangeNotSatisfiable:
// 分片比上游对象还长(对象换过):丢掉分片重来。
drainAndClose(resp.Body)
if sink != nil && offset > 0 {
_ = file.Close()
return "", errAudioStreamCannotRestart
}
offset = 0
_ = file.Truncate(0)
lastErr = fmt.Errorf("音源分片已失效(%d)", resp.StatusCode)
continue
default:
drainAndClose(resp.Body)
lastErr = fmt.Errorf("音源返回 %d", resp.StatusCode)
continue
}
if sink != nil && !started {
// 直通模式下要在这里就把状态码与 Content-Length 写出去,客户端才能
// 边收边播。写头的失败(客户端已断开)当作停止下载处理。
if onStart != nil {
onStart(expected, resp.Header.Get("Content-Type"))
}
started = true
}
if _, seekErr := file.Seek(offset, io.SeekStart); seekErr != nil {
drainAndClose(resp.Body)
lastErr = fmt.Errorf("音频缓存定位失败: %w", seekErr)
continue
}
var written int64
var writeErr error
if sink != nil {
written, writeErr = writeAudioChunkStream(file, sink, head, resp.Body, audioMediaObjectMaxBytes-offset)
} else {
written, writeErr = writeAudioChunk(file, head, resp.Body, audioMediaObjectMaxBytes-offset)
}
_ = resp.Body.Close()
offset += written
if errors.Is(writeErr, errAudioObjectTooLarge) {
_ = file.Close()
_ = os.Remove(partPath)
return "", fmt.Errorf("已重试 %d 次: %w", attempts, writeErr)
}
var sinkErr *audioSinkError
if errors.As(writeErr, &sinkErr) {
// 客户端不要了:停止下载,已写入的分片留着供下次续传。
_ = file.Close()
return "", sinkErr
}
if writeErr != nil {
// 已写入的字节保留在分片里,下一轮从新的断点续传。
lastErr = writeErr
continue
}
if expected >= 0 && offset != expected {
lastErr = fmt.Errorf("音频写入不完整(%d/%d 字节)", offset, expected)
continue
}
if offset == 0 {
lastErr = errors.New("音源内容为空")
continue
}
if truncErr := file.Truncate(offset); truncErr != nil {
_ = file.Close()
return "", fmt.Errorf("写入音频缓存失败: %w", truncErr)
}
if closeErr := file.Close(); closeErr != nil {
return "", fmt.Errorf("写入音频缓存失败: %w", closeErr)
}
if renameErr := os.Rename(partPath, path); renameErr != nil {
return "", fmt.Errorf("写入音频缓存失败: %w", renameErr)
}
s.pruneAudioMediaCache()
return path, nil
}
_ = file.Close()
if lastErr == nil {
lastErr = errors.New("音源拉取失败")
}
if offset > 0 {
return "", fmt.Errorf("已重试 %d 次: %w(已缓存 %d 字节,重试可续传)", attempts, lastErr, offset)
}
return "", fmt.Errorf("已重试 %d 次: %w", attempts, lastErr)
}
// writeAudioChunk 先把已读前缀补写回文件,再把响应体写到 limit 字节为止。
// 返回本次实际写入的字节数(出错时也包含已写成功的部分)。
func writeAudioChunk(w io.Writer, head []byte, body io.Reader, limit int64) (int64, error) {
var total int64
if len(head) > 0 {
n, err := w.Write(head)
total += int64(n)
if err != nil {
return total, err
}
}
if limit <= 0 {
return total, errAudioObjectTooLarge
}
n, err := io.Copy(w, io.LimitReader(body, limit-total+1))
total += n
if err != nil {
return total, err
}
if total > limit {
return total, errAudioObjectTooLarge
}
return total, nil
}
// writeAudioChunkStream 同 writeAudioChunk,但同时把字节写给客户端(首播直通)。
//
// 写盘失败要让整个下载停下来(本地状态不可信,继续发字节等于给客户端一份
// 服务端存不下的内容);写客户端失败只说明接收方不要了,用 audioSinkError
// 标出来让上层保留分片、安静收尾。两者必须区分,否则播放器一关就把分片删了。
func writeAudioChunkStream(w io.Writer, sink io.Writer, head []byte, body io.Reader, limit int64) (int64, error) {
var total int64
writeBoth := func(p []byte) error {
n, err := w.Write(p)
total += int64(n)
if err != nil {
return err
}
if n > 0 {
if _, sinkErr := sink.Write(p[:n]); sinkErr != nil {
return &audioSinkError{err: sinkErr}
}
}
return nil
}
if len(head) > 0 {
if err := writeBoth(head); err != nil {
return total, err
}
}
if limit <= 0 {
return total, errAudioObjectTooLarge
}
buf := make([]byte, 32<<10)
remaining := limit - total + 1
for {
if remaining <= 0 {
return total, errAudioObjectTooLarge
}
chunk := buf
if int64(len(chunk)) > remaining {
chunk = chunk[:remaining]
}
n, readErr := body.Read(chunk)
if n > 0 {
if err := writeBoth(chunk[:n]); err != nil {
return total, err
}
remaining -= int64(n)
}
if readErr == io.EOF {
break
}
if readErr != nil {
return total, readErr
}
}
if total > limit {
return total, errAudioObjectTooLarge
}
return total, nil
}
// parseContentRange 解析 "bytes 100-199/1234" 的起点与总长度。
// 总长度未知("*")时 total 为 -1。
func parseContentRange(v string) (start, total int64, ok bool) {
v = strings.TrimSpace(v)
const prefix = "bytes "
if len(v) <= len(prefix) || !strings.EqualFold(v[:len(prefix)], prefix) {
return 0, -1, false
}
v = strings.TrimSpace(v[len(prefix):])
slash := strings.IndexByte(v, '/')
if slash < 0 {
return 0, -1, false
}
span, size := v[:slash], strings.TrimSpace(v[slash+1:])
dash := strings.IndexByte(span, '-')
if dash <= 0 {
return 0, -1, false
}
start, err := strconv.ParseInt(strings.TrimSpace(span[:dash]), 10, 64)
if err != nil || start < 0 {
return 0, -1, false
}
if size == "" || size == "*" {
return start, -1, true
}
total, err = strconv.ParseInt(size, 10, 64)
if err != nil || total <= 0 {
return start, -1, true
}
return start, total, true
}
// drainAndClose 丢弃剩余响应体并关闭连接(尽量让上游把连接收回去)。
func drainAndClose(body io.ReadCloser) {
if body == nil {
return
}
_, _ = io.Copy(io.Discard, io.LimitReader(body, 64<<10))
_ = body.Close()
}
// pruneAudioMediaCache 低频清理:删过期文件,超容量时按 mtime 淘汰最旧的。
func (s *ReaderService) pruneAudioMediaCache() {
dir := s.audioMediaDir()
if dir == "" {
return
}
audioMediaPruneMu.Lock()
if time.Since(audioMediaPrunedAt) < audioMediaPrunePeriod {
audioMediaPruneMu.Unlock()
return
}
audioMediaPrunedAt = time.Now()
audioMediaPruneMu.Unlock()
type entry struct {
path string
size int64
mod time.Time
}
var entries []entry
var total int64
_ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
info, infoErr := d.Info()
if infoErr != nil {
return nil
}
if info.ModTime().Add(audioMediaTTL).Before(time.Now()) {
_ = os.Remove(path)
return nil
}
entries = append(entries, entry{path: path, size: info.Size(), mod: info.ModTime()})
total += info.Size()
return nil
})
if total <= audioMediaMaxBytes {
return
}
sort.Slice(entries, func(i, j int) bool { return entries[i].mod.Before(entries[j].mod) })
target := int64(audioMediaMaxBytes) * audioMediaPruneKeepPct / 100
for _, e := range entries {
if total <= target {
break
}
if err := os.Remove(e.path); err == nil {
total -= e.size
}
}
}
@@ -0,0 +1,434 @@
package reader
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/truewhile/MeBox/internal/config"
)
func TestIsAudioMediaURL(t *testing.T) {
cases := []struct {
name string
url string
bookType int
want bool
}{
{"m4a 直链", "https://cdn.example.com/a.m4a?uid=1&vkey=x", 0, true},
{"mp3 直链", "https://cdn.example.com/a.MP3", 0, true},
{"音频书里的插图", "https://cdn.example.com/img/cover.jpg", 1, false},
{"HLS 播放列表", "https://cdn.example.com/index.m3u8?token=1", 1, false},
{"无后缀签名直链(音频书)", "https://cdn.example.com/stream?token=1", 1, true},
{"无后缀签名直链(文本/图片书)", "https://cdn.example.com/stream?token=1", 0, false},
{"坏地址但书籍是音频", "://bad", 1, true},
}
for _, c := range cases {
if got := IsAudioMediaURL(c.url, c.bookType); got != c.want {
t.Errorf("%s: IsAudioMediaURL(%q, %d) = %v,期望 %v", c.name, c.url, c.bookType, got, c.want)
}
}
}
// TestAudioMediaCacheKeyIgnoresQuery 换票会改写 uid/vkey/ct,缓存键必须忽略
// 这些签名/时效参数,否则每次换票都会重新下载一遍。
func TestAudioMediaCacheKeyIgnoresQuery(t *testing.T) {
a := audioMediaCacheKey("book-1", "https://cdn.example.com/long/a.m4a?uid=1&vkey=aaa")
b := audioMediaCacheKey("book-1", "https://cdn.example.com/long/a.m4a?uid=2&vkey=bbb&ct=3")
if a == "" || a != b {
t.Fatalf("同一对象换票后缓存键应一致: %q vs %q", a, b)
}
if c := audioMediaCacheKey("book-2", "https://cdn.example.com/long/a.m4a"); c == a {
t.Fatalf("不同书籍的缓存键不应相同")
}
if d := audioMediaCacheKey("book-1", "https://cdn.example.com/long/b.m4a"); d == a {
t.Fatalf("不同对象的缓存键不应相同")
}
}
// TestAudioMediaCacheKeyKeepsIdentifyingQuery 只靠 query 区分章节的书源
// (路径固定、如 /audio?id=12345)不能被合并成同一个缓存对象,否则第二集
// 会命中第一集的缓存直接播错内容。
func TestAudioMediaCacheKeyKeepsIdentifyingQuery(t *testing.T) {
ep1 := audioMediaCacheKey("book-1", "https://host/audio?id=12345&vkey=aaa")
ep2 := audioMediaCacheKey("book-1", "https://host/audio?id=67890&vkey=bbb")
if ep1 == "" || ep2 == "" {
t.Fatal("缓存键不应为空")
}
if ep1 == ep2 {
t.Fatalf("不同章节(query 不同)的缓存键不应相同,否则会串集")
}
}
// TestAudioMediaCacheKeyIgnoresQueryOrder 非签名参数的顺序不应影响缓存键。
func TestAudioMediaCacheKeyIgnoresQueryOrder(t *testing.T) {
a := audioMediaCacheKey("book-1", "https://host/a.m4a?id=7&chapter=3&vkey=x")
b := audioMediaCacheKey("book-1", "https://host/a.m4a?chapter=3&id=7&vkey=y")
if a == "" || a != b {
t.Fatalf("参数顺序不应影响缓存键: %q vs %q", a, b)
}
}
// ─── 断点续传下载器 ──────────────────────────────────────────────────────
func TestParseContentRange(t *testing.T) {
cases := []struct {
in string
start int64
total int64
wantOK bool
}{
{"bytes 100-199/1234", 100, 1234, true},
{"bytes 0-99/*", 0, -1, true},
{"BYTES 500-599/600", 500, 600, true},
{"", 0, -1, false},
{"bytes */1234", 0, -1, false},
{"bytes x-1/2", 0, -1, false},
}
for _, c := range cases {
start, total, ok := parseContentRange(c.in)
if ok != c.wantOK || start != c.start || total != c.total {
t.Errorf("parseContentRange(%q) = (%d,%d,%v),期望 (%d,%d,%v)",
c.in, start, total, ok, c.start, c.total, c.wantOK)
}
}
}
// errAfterReader 读到 data 末尾后返回 err,模拟上游中途 RST/提前 EOF。
type errAfterReader struct {
data []byte
pos int
err error
}
func (r *errAfterReader) Read(p []byte) (int, error) {
if r.pos >= len(r.data) {
return 0, r.err
}
n := copy(p, r.data[r.pos:])
r.pos += n
return n, nil
}
func (r *errAfterReader) Close() error { return nil }
func newAudioDownloadTestService(t *testing.T) *ReaderService {
t.Helper()
return &ReaderService{cfg: &config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}}
}
func okResponse(body []byte) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
ContentLength: int64(len(body)),
Header: http.Header{"Content-Type": {"audio/mp4"}},
Body: io.NopCloser(bytes.NewReader(body)),
}
}
func rangeResponse(header string, body []byte) *http.Response {
return &http.Response{
StatusCode: http.StatusPartialContent,
Header: http.Header{"Content-Range": {header}},
Body: io.NopCloser(bytes.NewReader(body)),
}
}
// TestDownloadRemoteAudioResumesAfterReset 上游写到一半断开后,第二轮必须用
// Range 从断点续传,最终文件与上游内容逐字节一致。
func TestDownloadRemoteAudioResumesAfterReset(t *testing.T) {
svc := newAudioDownloadTestService(t)
const mediaURL = "https://cdn.example.com/long/a.m4a?vkey=1"
payload := bytes.Repeat([]byte("0123456789"), 500) // 5000 字节
const firstChunk = 2000
var ranges []string
attempt := 0
path, err := svc.DownloadRemoteAudio(t.Context(), "book-1", mediaURL, 3,
func(rangeHeader string) (*http.Response, []byte, error) {
ranges = append(ranges, rangeHeader)
attempt++
if attempt == 1 {
// 声明全量长度,只写 2000 字节就断开。
return &http.Response{
StatusCode: http.StatusOK,
ContentLength: int64(len(payload)),
Header: http.Header{"Content-Type": {"audio/mp4"}},
Body: &errAfterReader{
data: payload[:firstChunk],
err: io.ErrUnexpectedEOF,
},
}, nil, nil
}
if rangeHeader != fmt.Sprintf("bytes=%d-", firstChunk) {
t.Fatalf("第二轮应请求断点续传,实际 Range=%q", rangeHeader)
}
return rangeResponse(
fmt.Sprintf("bytes %d-%d/%d", firstChunk, len(payload)-1, len(payload)),
payload[firstChunk:],
), nil, nil
})
if err != nil {
t.Fatalf("续传后应当成功: %v", err)
}
got, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatalf("读缓存文件失败: %v", readErr)
}
if !bytes.Equal(got, payload) {
t.Fatalf("续传后的内容不完整: %d 字节,期望 %d", len(got), len(payload))
}
if len(ranges) != 2 || ranges[0] != "" {
t.Fatalf("请求序列 = %q,期望首次整段、第二次续传", ranges)
}
}
// TestDownloadRemoteAudioResumesAcrossCalls 上一次请求失败留下的分片,下一次
// (用户点重试)要能直接接着下,而不是从第 0 字节重来。
func TestDownloadRemoteAudioResumesAcrossCalls(t *testing.T) {
svc := newAudioDownloadTestService(t)
const mediaURL = "https://cdn.example.com/long/b.m4a"
payload := bytes.Repeat([]byte("abcdefgh"), 1000)
const firstChunk = 3000
// 第一次调用:上游只给 3000 字节就断,重试也没用(每次都 200 + 截断)。
_, err := svc.DownloadRemoteAudio(t.Context(), "book-2", mediaURL, 1,
func(rangeHeader string) (*http.Response, []byte, error) {
return &http.Response{
StatusCode: http.StatusOK,
ContentLength: int64(len(payload)),
Header: http.Header{"Content-Type": {"audio/mp4"}},
Body: &errAfterReader{
data: payload[:firstChunk],
err: io.ErrUnexpectedEOF,
},
}, nil, nil
})
if err == nil {
t.Fatalf("上游截断时应当失败")
}
// 第二次调用:直接请求 bytes=3000-,续完。
var seenRange string
path, err := svc.DownloadRemoteAudio(t.Context(), "book-2", mediaURL, 1,
func(rangeHeader string) (*http.Response, []byte, error) {
seenRange = rangeHeader
return rangeResponse(
fmt.Sprintf("bytes %d-%d/%d", firstChunk, len(payload)-1, len(payload)),
payload[firstChunk:],
), nil, nil
})
if err != nil {
t.Fatalf("跨请求续传应当成功: %v", err)
}
if seenRange != fmt.Sprintf("bytes=%d-", firstChunk) {
t.Fatalf("应当从上次的断点续传,实际 Range=%q", seenRange)
}
got, _ := os.ReadFile(path)
if !bytes.Equal(got, payload) {
t.Fatalf("跨请求续传后的内容不完整: %d 字节", len(got))
}
}
// TestDownloadRemoteAudioRestartsWhenRangeIgnored 上游忽略 Range 仍回 200 时,
// 必须丢掉分片从头写,不能把两段字节拼在一起。
func TestDownloadRemoteAudioRestartsWhenRangeIgnored(t *testing.T) {
svc := newAudioDownloadTestService(t)
const mediaURL = "https://cdn.example.com/long/c.m4a"
payload := bytes.Repeat([]byte("xyz9"), 500)
var ranges []string
attempt := 0
path, err := svc.DownloadRemoteAudio(t.Context(), "book-3", mediaURL, 3,
func(rangeHeader string) (*http.Response, []byte, error) {
ranges = append(ranges, rangeHeader)
attempt++
if attempt == 1 {
return &http.Response{
StatusCode: http.StatusOK,
ContentLength: int64(len(payload)),
Header: http.Header{"Content-Type": {"audio/mp4"}},
Body: &errAfterReader{data: payload[:700], err: io.ErrUnexpectedEOF},
}, nil, nil
}
// 无视 Range,整段回 200。
return okResponse(payload), nil, nil
})
if err != nil {
t.Fatalf("应当成功: %v", err)
}
if ranges[1] == "" {
t.Fatalf("第二轮应当带 Range")
}
got, _ := os.ReadFile(path)
if !bytes.Equal(got, payload) {
t.Fatalf("忽略 Range 后必须从头重写,实际 %d 字节(期望 %d)", len(got), len(payload))
}
}
// TestDownloadRemoteAudioStopsOnNoRetry 上游成批拒绝时(NoRetryError),
// 下载器要立刻收手,不能把同一张被拒的票再打几遍。
func TestDownloadRemoteAudioStopsOnNoRetry(t *testing.T) {
svc := newAudioDownloadTestService(t)
denied := errors.New("音源暂时被上游拒绝(HTTP 401)")
calls := 0
_, err := svc.DownloadRemoteAudio(t.Context(), "book-4", "https://cdn.example.com/long/d.m4a", 4,
func(rangeHeader string) (*http.Response, []byte, error) {
calls++
return nil, nil, &NoRetryError{Err: denied}
})
if err == nil || !strings.Contains(err.Error(), "401") {
t.Fatalf("应原样返回上游拒绝原因,实际 %v", err)
}
if calls != 1 {
t.Fatalf("不可重试的失败只应请求 1 次,实际 %d 次", calls)
}
}
// ─── 首播直通(边下边发) ────────────────────────────────────────────────
// TestDownloadRemoteAudioStreamingForwardsWhileDownloading 直通模式:字节必须
// 在下载过程中就到达 sink,而不是等整段下完才一次性写出去(首播 TTFB)。
func TestDownloadRemoteAudioStreamingForwardsWhileDownloading(t *testing.T) {
svc := newAudioDownloadTestService(t)
const mediaURL = "https://cdn.example.com/long/stream.m4a? vkey=1"
payload := bytes.Repeat([]byte("stream!"), 1000)
var sink bytes.Buffer
var startedTotal int64
// 用「读到一半时 sink 里已经有数据」来证明确实在边下边发。
var midLength int
path, err := svc.DownloadRemoteAudioStreaming(t.Context(), "book-s", mediaURL, 1,
func(rangeHeader string) (*http.Response, []byte, error) {
if rangeHeader != "" {
t.Fatalf("首播直通不应带 Range,实际 %q", rangeHeader)
}
return okResponse(payload), nil, nil
},
func(total int64, contentType string) {
startedTotal = total
// 头部回调必须早于任何字节下发。
if sink.Len() != 0 {
t.Errorf("写响应头时不应已有字节写出")
}
},
writerFunc(func(p []byte) (int, error) {
if midLength == 0 && sink.Len() == 0 && len(p) > 0 {
midLength = sink.Len()
}
return sink.Write(p)
}),
)
if err != nil {
t.Fatalf("直通下载失败: %v", err)
}
if startedTotal != int64(len(payload)) {
t.Fatalf("头部回调应拿到上游总长 %d,实际 %d", len(payload), startedTotal)
}
if !bytes.Equal(sink.Bytes(), payload) {
t.Fatalf("下发的字节不完整: %d 字节,期望 %d", sink.Len(), len(payload))
}
// 下载完成后缓存文件必须完整可命中。
got, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatalf("读缓存文件失败: %v", readErr)
}
if !bytes.Equal(got, payload) {
t.Fatalf("落盘内容不完整: %d 字节", len(got))
}
if !svc.HasCachedRemoteAudio("book-s", mediaURL) {
t.Fatal("直通下完后应当命中缓存")
}
}
// TestDownloadRemoteAudioStreamingStopsWhenClientGoesAway 客户端中途断开时
// 停止下载,但保留分片供下次续传(不能把已下的字节白扔掉)。
func TestDownloadRemoteAudioStreamingStopsWhenClientGoesAway(t *testing.T) {
svc := newAudioDownloadTestService(t)
const mediaURL = "https://cdn.example.com/long/gone.m4a"
payload := bytes.Repeat([]byte("abcdefgh"), 2000)
clientGone := errors.New("client closed connection")
var sink bytes.Buffer
_, err := svc.DownloadRemoteAudioStreaming(t.Context(), "book-g", mediaURL, 1,
func(rangeHeader string) (*http.Response, []byte, error) {
return okResponse(payload), nil, nil
},
nil,
writerFunc(func(p []byte) (int, error) {
// 收下前 1000 字节后模拟播放器断开。
if sink.Len() >= 1000 {
return 0, clientGone
}
room := 1000 - sink.Len()
if len(p) <= room {
return sink.Write(p)
}
n, _ := sink.Write(p[:room])
return n, clientGone
}),
)
if err == nil {
t.Fatal("客户端断开时应当停止下载")
}
var sinkErr *audioSinkError
if !errors.As(err, &sinkErr) {
t.Fatalf("应标记为写客户端失败(便于上层区分),实际 %v", err)
}
// 分片要留着:这是续传的原料。
path := svc.RemoteAudioCachePath("book-g", mediaURL)
if path == "" {
t.Fatal("缓存路径不应为空")
}
if info, statErr := os.Stat(path + audioPartialSuffix); statErr != nil || info.Size() == 0 {
t.Fatalf("断流后应保留 .part 分片,实际 %v", statErr)
}
if svc.HasCachedRemoteAudio("book-g", mediaURL) {
t.Fatal("中断的下载不能留下完整缓存(否则会下发截断音频)")
}
}
// TestDownloadRemoteAudioStreamingAbortsOnRestart 直通模式下上游忽略 Range 回
// 200(需要从 0 重来)时必须中止,而不是把重复字节拼进客户端已收到的流里。
func TestDownloadRemoteAudioStreamingAbortsOnRestart(t *testing.T) {
svc := newAudioDownloadTestService(t)
const mediaURL = "https://cdn.example.com/long/restart.m4a"
payload := bytes.Repeat([]byte("zz"), 100)
// 先留一个分片,让第二轮带 Range。
partPath := svc.RemoteAudioCachePath("book-r", mediaURL) + audioPartialSuffix
if err := os.MkdirAll(filepath.Dir(partPath), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(partPath, payload[:50], 0o640); err != nil {
t.Fatal(err)
}
var sink bytes.Buffer
_, err := svc.DownloadRemoteAudioStreaming(t.Context(), "book-r", mediaURL, 1,
func(rangeHeader string) (*http.Response, []byte, error) {
// 无视 Range,整段回 200 → 直通无法重来。
return okResponse(payload), nil, nil
},
nil,
&sink,
)
if !errors.Is(err, errAudioStreamCannotRestart) {
t.Fatalf("应返回不可重来错误,实际 %v", err)
}
if sink.Len() != 0 {
t.Fatalf("中止前不应向客户端写出字节,实际 %d", sink.Len())
}
}
// writerFunc 把函数适配成 io.Writer。
type writerFunc func(p []byte) (int, error)
func (f writerFunc) Write(p []byte) (int, error) { return f(p) }
@@ -58,7 +58,7 @@ func TestAudioBookTypeFallbackBySource(t *testing.T) {
t.Fatalf("保存目录失败: %v", err)
}
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{})
if err != nil {
t.Fatalf("取正文失败: %v", err)
}
@@ -125,7 +125,7 @@ func TestDeclaredTextTypeWinsOverAudioSource(t *testing.T) {
t.Fatalf("保存目录失败: %v", err)
}
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{})
if err != nil {
t.Fatalf("取正文失败: %v", err)
}
+1 -1
View File
@@ -75,7 +75,7 @@ func TestGetContentForBookExtractsImageSrc(t *testing.T) {
}
_ = sourceID
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{})
if err != nil {
t.Fatalf("取正文失败: %v", err)
}
+3 -3
View File
@@ -207,7 +207,7 @@ func TestGetContentForBookExtractsComments(t *testing.T) {
t.Fatalf("写入章节失败: %v", err)
}
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{})
if err != nil {
t.Fatalf("取正文失败: %v", err)
}
@@ -282,7 +282,7 @@ func TestGetContentForBookKeepsTextWithStandaloneImage(t *testing.T) {
`<img src="https://cdn.example.com/pic.jpg">`
svc, bookID := setupTextChapterBook(t, content)
out, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0)
out, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0, ContentOptions{})
if err != nil {
t.Fatalf("取正文失败: %v", err)
}
@@ -310,7 +310,7 @@ func TestGetContentForBookStripsBlockTagsKeepsText(t *testing.T) {
`<img src="https://cdn.example.com/pic.jpg">`
svc, bookID := setupTextChapterBook(t, content)
out, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0)
out, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0, ContentOptions{})
if err != nil {
t.Fatalf("取正文失败: %v", err)
}

Some files were not shown because too many files have changed in this diff Show More