From 079b3ff06301dd2b893802c8e34fe1177d8cc244 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:44:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=85=BC=E5=AE=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/application.go | 2 +- internal/config/defaults.go | 1 + internal/config/types.go | 4 + internal/database/database_admin.go | 2 +- internal/database/schema_migration.go | 186 +++++++++++- .../schema_migration_path_index_test.go | 54 ++++ internal/model/library_media.go | 18 +- internal/repository/dialect.go | 21 ++ internal/repository/dialect_test.go | 54 ++++ internal/repository/history_repository.go | 20 +- .../history_repository_dialect_test.go | 65 ++++ .../repository/media_search_repository.go | 7 +- .../repository/media_segment_repository.go | 48 +-- internal/repository/reader_repository.go | 10 +- .../reader_repository_dialect_test.go | 11 +- internal/service/backup.go | 82 +++-- internal/service/backup_postgres.go | 279 ++++++++++++++++++ internal/service/backup_postgres_test.go | 226 ++++++++++++++ internal/service/emby_episodic_path_test.go | 82 +++++ internal/service/emby_items_list.go | 13 +- internal/service/emby_movie_items.go | 25 +- internal/service/local_availability.go | 5 +- web/src/pages/PlayerPage.tsx | 173 +++++++---- 23 files changed, 1246 insertions(+), 142 deletions(-) create mode 100644 internal/database/schema_migration_path_index_test.go create mode 100644 internal/repository/dialect.go create mode 100644 internal/repository/dialect_test.go create mode 100644 internal/repository/history_repository_dialect_test.go create mode 100644 internal/service/backup_postgres.go create mode 100644 internal/service/backup_postgres_test.go create mode 100644 internal/service/emby_episodic_path_test.go diff --git a/cmd/server/application.go b/cmd/server/application.go index 2056c29..1c87825 100644 --- a/cmd/server/application.go +++ b/cmd/server/application.go @@ -88,7 +88,7 @@ func (a *application) start() error { a.logger.Error("database not ready", zap.Error(err)) return fmt.Errorf("database not ready: %w", err) } - if err := database.AutoMigrate(db); err != nil { + if err := database.AutoMigrateWithConfig(db, a.cfg, a.logger); err != nil { a.logger.Error("auto-migrate failed", zap.Error(err)) return fmt.Errorf("auto-migrate failed: %w", err) } diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 6649259..add9619 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -33,6 +33,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("database.cache_size", -40000) v.SetDefault("database.max_open_conns", defaultDatabaseMaxOpenConns) v.SetDefault("database.max_idle_conns", defaultDatabaseMaxIdleConns) + v.SetDefault("database.postgres_trgm_index", true) v.SetDefault("secrets.jwt_secret", "") diff --git a/internal/config/types.go b/internal/config/types.go index daf197f..1560711 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -86,6 +86,10 @@ type DatabaseConfig struct { CacheSize int `mapstructure:"cache_size"` MaxOpenConns int `mapstructure:"max_open_conns"` MaxIdleConns int `mapstructure:"max_idle_conns"` + // PostgresTrgmIndex 决定是否用 pg_trgm 的 GIN 三元组索引替代 SQLite 的 + // FTS5 全文索引。开启后 ILIKE '%term%' 搜索能走索引而不是整表扫描,代价是 + // media 表写入时要多维护 4 个 GIN 索引。仅对 PostgreSQL 生效。 + PostgresTrgmIndex bool `mapstructure:"postgres_trgm_index"` } // SecretsConfig 保存 JWT / 第三方 API 密钥(不要提交值)。 diff --git a/internal/database/database_admin.go b/internal/database/database_admin.go index dd02d65..bfc0518 100644 --- a/internal/database/database_admin.go +++ b/internal/database/database_admin.go @@ -165,7 +165,7 @@ func MigrateCurrentToPostgres(src *gorm.DB, targetDSN string, batchSize int, log } // 1. 初始化目标库 Schema、类型与索引 - if err := AutoMigrate(targetDB); err != nil { + if err := AutoMigrateWithConfig(targetDB, nil, log); err != nil { return nil, fmt.Errorf("auto migrate target PostgreSQL: %w", err) } diff --git a/internal/database/schema_migration.go b/internal/database/schema_migration.go index a9cc436..6839c32 100644 --- a/internal/database/schema_migration.go +++ b/internal/database/schema_migration.go @@ -1,13 +1,25 @@ package database import ( + "fmt" + "strings" + + "go.uber.org/zap" "gorm.io/gorm" + "github.com/truewhile/MeBox/internal/config" "github.com/truewhile/MeBox/internal/model" ) // AutoMigrate creates tables for every model registered in the model package. func AutoMigrate(db *gorm.DB) error { + return AutoMigrateWithConfig(db, nil, nil) +} + +// AutoMigrateWithConfig 与 AutoMigrate 相同,另外接受配置与日志: +// 配置用来决定可选的 PostgreSQL 搜索索引,日志用来记录耗时步骤 +// (首次在已有大库上重建索引可能需要数十秒)。两者都可以为 nil。 +func AutoMigrateWithConfig(db *gorm.DB, cfg *config.Config, log *zap.Logger) error { // 必须先于 AutoMigrate:旧库中可能已有重复的 (user_id, media_id) 历史行, // 不去重会导致唯一索引 uniq_user_history 创建失败。 if err := dedupePlaybackHistories(db); err != nil { @@ -32,6 +44,9 @@ func AutoMigrate(db *gorm.DB) error { if err := ensurePerformanceIndexes(db); err != nil { return err } + if err := ensureMediaPathIndex(db); err != nil { + return err + } if err := ensureLibraryRootsCompatibility(db); err != nil { return err } @@ -44,6 +59,111 @@ func AutoMigrate(db *gorm.DB) error { } return ensureSQLiteQueryOptimizer(db) } + return ensurePostgresSearchIndexes(db, cfg, log) +} + +// ensureMediaPathIndex 建立 media.path 的唯一约束与等值查找索引。 +// +// 模型上不再用 gorm:"uniqueIndex"(见 model.Media.Path 的注释):PostgreSQL 的 +// btree 单条索引项上限是 2704 字节,varchar(1024) 在中文路径下可达 3072 字节, +// 对整列建唯一索引会让这类行插入时报 +// +// index row size ... exceeds btree version 4 maximum 2704 +// +// 所以 PostgreSQL 改用 md5(path) 的表达式唯一索引(定长 32 字节,永远塞得下), +// 同时保留一条普通索引 idx_media_path 供 path = ? 等值查找(扫描与 upsert 的 +// 热路径)使用。SQLite 没有这个长度限制,沿用整列唯一索引。 +func ensureMediaPathIndex(db *gorm.DB) error { + if db == nil || !db.Migrator().HasTable("media") { + return nil + } + if !isPostgres(db) { + return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_media_path ON media(path)`).Error + } + // 先建哈希唯一索引:此时旧索引还在,唯一性不会出现空窗。 + if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_media_path_hash ON media(md5(path))`).Error; err != nil { + return err + } + unique, found, err := postgresIndexIsUnique(db, "idx_media_path") + if err != nil { + return err + } + if found && unique { + if err := db.Exec(`DROP INDEX IF EXISTS idx_media_path`).Error; err != nil { + return err + } + } + return db.Exec(`CREATE INDEX IF NOT EXISTS idx_media_path ON media(path)`).Error +} + +// postgresIndexIsUnique 报告当前 schema 下是否存在该索引、以及它是否唯一。 +func postgresIndexIsUnique(db *gorm.DB, indexName string) (unique bool, found bool, err error) { + // 用指针接 bool:没有匹配行时 Scan 不报错,nil 即代表索引不存在。 + var row struct { + IsUnique *bool + } + if err := db.Raw(` +SELECT i.indisunique AS is_unique +FROM pg_class c +JOIN pg_index i ON i.indexrelid = c.oid +JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE n.nspname = current_schema() AND c.relname = ? +`, indexName).Scan(&row).Error; err != nil { + return false, false, err + } + if row.IsUnique == nil { + return false, false, nil + } + return *row.IsUnique, true, nil +} + +// ensurePostgresSearchIndexes 给 PostgreSQL 补上 SQLite FTS5 的替代品。 +// +// 全文检索只在 SQLite 下存在(FTS5 虚表 + 触发器),PostgreSQL 下 +// media_search_repository 会退回 title/original_name/path/genres 的 ILIKE +// 全表扫描。pg_trgm 的 GIN 三元组索引能让这类 '%term%' 模式(≥3 字符)走索引, +// 把搜索从整表扫描降到索引扫描。 +// +// 之所以可关闭:media 表写入非常频繁(每次扫描批量入库),多 4 个 GIN 索引会 +// 明显放大写入成本。默认开启以对齐 SQLite 的搜索能力,写入压力大的部署可以关掉。 +// +// 整个过程是尽力而为:没有建扩展的权限、Postgres 版本不支持等情况下只记一条 +// 警告并继续启动,绝不让一个搜索加速项把服务挡在门外。 +func ensurePostgresSearchIndexes(db *gorm.DB, cfg *config.Config, log *zap.Logger) error { + if !isPostgres(db) { + return nil + } + if cfg != nil && !cfg.Database.PostgresTrgmIndex { + if log != nil { + log.Info("postgres trigram search index disabled; media search falls back to sequential scan") + } + return nil + } + if err := db.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`).Error; err != nil { + if log != nil { + log.Warn("create pg_trgm extension failed; media search falls back to sequential scan", zap.Error(err)) + } + return nil + } + // 首次在已有大库上执行会重建索引(服务尚未开始接受请求),之后每次启动都因 + // IF NOT EXISTS 直接跳过。 + if log != nil { + log.Info("ensuring postgres trigram search indexes") + } + for _, stmt := range []string{ + `CREATE INDEX IF NOT EXISTS idx_media_title_trgm ON media USING gin (title gin_trgm_ops)`, + `CREATE INDEX IF NOT EXISTS idx_media_original_name_trgm ON media USING gin (original_name gin_trgm_ops)`, + `CREATE INDEX IF NOT EXISTS idx_media_path_trgm ON media USING gin (path gin_trgm_ops)`, + `CREATE INDEX IF NOT EXISTS idx_media_genres_trgm ON media USING gin (genres gin_trgm_ops)`, + } { + if err := db.Exec(stmt).Error; err != nil { + if log != nil { + log.Warn("create trigram index failed; media search may fall back to sequential scan", + zap.String("statement", stmt), zap.Error(err)) + } + return nil + } + } return nil } @@ -176,16 +296,35 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error { if !isPostgres(db) { return nil } - statements := []string{ - `ALTER TABLE media ALTER COLUMN container TYPE varchar(128)`, - `ALTER TABLE media ALTER COLUMN genres TYPE text`, - `ALTER TABLE media ALTER COLUMN series_id TYPE varchar(128)`, - `ALTER TABLE media ALTER COLUMN duplicate_of TYPE varchar(128)`, - `ALTER TABLE playback_histories ALTER COLUMN media_id TYPE varchar(128)`, - `ALTER TABLE favorites ALTER COLUMN media_id TYPE varchar(128)`, - `ALTER TABLE playlist_items ALTER COLUMN media_id TYPE varchar(128)`, + // 只在类型确实不匹配时才 ALTER。 + // + // PostgreSQL 的 ALTER COLUMN ... TYPE 只要类型声明与目标不同就会整表重写并 + // 重建相关索引,执行期间持有 ACCESS EXCLUSIVE 锁。此前这里每次启动都无条件 + // 发这 7 条语句:类型其实早已一致,但几十万行的 media 表仍然被反复重写, + // 启动会卡住数分钟,且期间这张表连读都被阻塞。 + changes := []struct { + table string + column string + target string + }{ + {"media", "container", "varchar(128)"}, + {"media", "genres", "text"}, + {"media", "series_id", "varchar(128)"}, + {"media", "duplicate_of", "varchar(128)"}, + {"playback_histories", "media_id", "varchar(128)"}, + {"favorites", "media_id", "varchar(128)"}, + {"playlist_items", "media_id", "varchar(128)"}, } - for _, stmt := range statements { + for _, change := range changes { + current, found, err := postgresColumnType(db, change.table, change.column) + if err != nil { + return err + } + if !found || current == change.target { + continue + } + stmt := "ALTER TABLE " + quoteIdent(change.table) + + " ALTER COLUMN " + quoteIdent(change.column) + " TYPE " + change.target if err := db.Exec(stmt).Error; err != nil { return err } @@ -193,6 +332,35 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error { return nil } +// postgresColumnType 返回列在当前 schema 下的规范化类型描述,例如 +// "varchar(128)" / "text"。列不存在时 found 为 false。 +func postgresColumnType(db *gorm.DB, table, column string) (value string, found bool, err error) { + var row struct { + DataType string + MaxLength *int64 + } + if err := db.Raw(` +SELECT data_type AS data_type, character_maximum_length AS max_length +FROM information_schema.columns +WHERE table_schema = current_schema() AND table_name = ? AND column_name = ? +`, table, column).Scan(&row).Error; err != nil { + return "", false, fmt.Errorf("inspect postgres column %s.%s: %w", table, column, err) + } + dataType := strings.ToLower(strings.TrimSpace(row.DataType)) + if dataType == "" { + return "", false, nil + } + // information_schema 用的是 SQL 标准名,映射回 GORM 标签里的写法再比较。 + switch dataType { + case "character varying", "varchar": + dataType = "varchar" + } + if row.MaxLength != nil && *row.MaxLength > 0 { + dataType = fmt.Sprintf("%s(%d)", dataType, *row.MaxLength) + } + return dataType, true, nil +} + func ensurePerformanceIndexes(db *gorm.DB) error { statements := []string{ // 完整多级排序索引:媒体库分页与首页预览的 ORDER BY diff --git a/internal/database/schema_migration_path_index_test.go b/internal/database/schema_migration_path_index_test.go new file mode 100644 index 0000000..2e33f0e --- /dev/null +++ b/internal/database/schema_migration_path_index_test.go @@ -0,0 +1,54 @@ +package database + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/truewhile/MeBox/internal/model" +) + +// TestAutoMigrateCreatesMediaPathUniqueIndex 守住 media.path 的唯一约束。 +// +// 模型上不再声明 gorm:"uniqueIndex"(PostgreSQL 的 btree 单条索引项上限是 +// 2704 字节,而 varchar(1024) 在中文路径下可达 3072 字节,对整列建唯一索引会让 +// 这类行插入直接失败),改由 AutoMigrate 里的 ensureMediaPathIndex 按方言显式 +// 建立。一旦这个调用被漏掉,扫描会静默产生重复的媒体行(同一文件被入库多次)。 +func TestAutoMigrateCreatesMediaPathUniqueIndex(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "mebox.db")), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatal(err) + } + // 必须在 TempDir 清理之前关掉连接,否则 Windows 上删除库文件会失败。 + // t.Cleanup 是后进先出:这里注册的关闭早于 t.TempDir 注册的删除。 + if sqlDB, err := db.DB(); err == nil { + t.Cleanup(func() { _ = sqlDB.Close() }) + } + if err := AutoMigrate(db); err != nil { + t.Fatalf("AutoMigrate: %v", err) + } + + var definition string + if err := db.Raw(`SELECT COALESCE(sql, '') FROM sqlite_master WHERE type = 'index' AND name = 'idx_media_path'`).Scan(&definition).Error; err != nil { + t.Fatal(err) + } + if definition == "" { + t.Fatal("AutoMigrate did not create idx_media_path") + } + if !strings.Contains(strings.ToUpper(definition), "UNIQUE") { + t.Fatalf("idx_media_path is not unique: %q", definition) + } + + if err := db.Create(&model.Media{Base: model.Base{ID: "m-1"}, Path: "/media/a.mkv"}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.Media{Base: model.Base{ID: "m-2"}, Path: "/media/a.mkv"}).Error; err == nil { + t.Fatal("a duplicate media path was accepted; the unique constraint is not enforced") + } +} diff --git a/internal/model/library_media.go b/internal/model/library_media.go index e1493de..d34adba 100644 --- a/internal/model/library_media.go +++ b/internal/model/library_media.go @@ -26,13 +26,17 @@ 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"` - Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"` + 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 字节,直接对整列建唯一索引会让长路径的扫描插入直接失败 + // (SQLite 没有这个限制)。见 database.ensureMediaPathIndex。 + Path string `gorm:"size:1024;not null" json:"path"` RelativePath string `gorm:"size:1024" json:"relative_path,omitempty"` SizeBytes int64 `json:"size_bytes"` DurationSec int `json:"duration_sec"` diff --git a/internal/repository/dialect.go b/internal/repository/dialect.go new file mode 100644 index 0000000..22546a8 --- /dev/null +++ b/internal/repository/dialect.go @@ -0,0 +1,21 @@ +package repository + +import "gorm.io/gorm" + +// LikeOperator 返回当前方言下「与 SQLite LIKE 行为一致」的模式匹配运算符。 +// +// SQLite 的 LIKE 对 ASCII 不区分大小写(默认编译选项),PostgreSQL 的 LIKE 区分 +// 大小写、只有 ILIKE 才不区分。同一个搜索词在两种方言下必须给出同样的结果集, +// 否则换库之后「inception」就再也搜不到「Inception」。 +// +// 供构造原始 SQL 片段的调用方(含 service 层)使用。 +func LikeOperator(db *gorm.DB) string { + return likeOperator(db) +} + +func likeOperator(db *gorm.DB) string { + if db != nil && db.Dialector != nil && db.Dialector.Name() == "postgres" { + return "ILIKE" + } + return "LIKE" +} diff --git a/internal/repository/dialect_test.go b/internal/repository/dialect_test.go new file mode 100644 index 0000000..78adfa0 --- /dev/null +++ b/internal/repository/dialect_test.go @@ -0,0 +1,54 @@ +package repository + +import ( + "strings" + "testing" + + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +// TestSegmentPrewarmQueryBindsBooleanFound 守住 PostgreSQL 下片段预取查询的合法性。 +// +// media_segment_fetches.found 是布尔列,而 PostgreSQL 没有 boolean = integer +// 运算符:写成 f.found = 0 / = 1 会让计划任务里的片段预取固定报 +// +// operator does not exist: boolean = integer (SQLSTATE 42883) +// +// SQLite 一直能跑,所以这个错只在 PostgreSQL 上暴露(表现为日志里周期性出现 +// 该错误、片头片尾数据永远补不上)。这里断言语句里只能出现占位符绑定。 +func TestSegmentPrewarmQueryBindsBooleanFound(t *testing.T) { + query := segmentPrewarmCandidatesQuery + lower := strings.ToLower(query) + for _, forbidden := range []string{"found = 0", "found = 1", "found=0", "found=1", "found = true", "found = false"} { + if strings.Contains(lower, forbidden) { + t.Fatalf("预取查询把布尔列与字面量比较(%q),PostgreSQL 会直接报错:\n%s", forbidden, query) + } + } + if got := strings.Count(query, "f.found = ?"); got != 2 { + t.Fatalf("expected 2 placeholder-bound found comparisons, got %d:\n%s", got, query) + } +} + +// TestLikeOperatorMatchesSQLiteCaseInsensitivity 守住搜索大小写行为的方言差异。 +// +// SQLite 的 LIKE 对 ASCII 不区分大小写,PostgreSQL 的 LIKE 区分大小写:同一个 +// 搜索词在两种方言下必须给出同样的结果集,PostgreSQL 必须走 ILIKE。 +func TestLikeOperatorMatchesSQLiteCaseInsensitivity(t *testing.T) { + pg, err := gorm.Open(postgres.New(postgres.Config{ + DSN: "postgres://mebox:mebox@127.0.0.1:5432/mebox?sslmode=disable", + }), &gorm.Config{ + DryRun: true, + DisableAutomaticPing: true, + SkipDefaultTransaction: true, + }) + if err != nil { + t.Fatalf("open dry-run postgres: %v", err) + } + if got := LikeOperator(pg); got != "ILIKE" { + t.Fatalf("postgres LikeOperator = %q, want ILIKE", got) + } + if got := LikeOperator(nil); got != "LIKE" { + t.Fatalf("nil LikeOperator = %q, want LIKE", got) + } +} diff --git a/internal/repository/history_repository.go b/internal/repository/history_repository.go index 6d3902f..e95c53f 100644 --- a/internal/repository/history_repository.go +++ b/internal/repository/history_repository.go @@ -33,6 +33,20 @@ func (r *HistoryRepository) UpsertProgress(ctx context.Context, h *model.Playbac return r.upsert(ctx, h, true) } +// historyColumn 生成「当前表」限定的列引用。 +// +// PostgreSQL 的 ON CONFLICT DO UPDATE ... WHERE 里,未限定的列名会同时可解析到 +// 目标表和本次插入的行,于是报 +// +// column reference "session_started_at_ms" is ambiguous (SQLSTATE 42702) +// +// 结果是 POST /api/history 一律 500,观看进度与续播完全存不下来。SQLite 不做这项 +// 检查,所以这个错只在 PostgreSQL 上暴露。clause.CurrentTable 由 GORM 在构建语句 +// 时替换成真实表名,两种方言都能正确渲染。 +func historyColumn(name string) clause.Column { + return clause.Column{Table: clause.CurrentTable, Name: name} +} + func (r *HistoryRepository) upsert(ctx context.Context, h *model.PlaybackHistory, versioned bool) error { updates := playbackHistoryAssignments(h) if versioned { @@ -49,16 +63,16 @@ func (r *HistoryRepository) upsert(ctx context.Context, h *model.PlaybackHistory onConflict.Where = clause.Where{Exprs: []clause.Expression{ clause.Or( clause.Lt{ - Column: clause.Column{Name: "session_started_at_ms"}, + Column: historyColumn("session_started_at_ms"), Value: h.SessionStartedAtMs, }, clause.And( clause.Eq{ - Column: clause.Column{Name: "session_started_at_ms"}, + Column: historyColumn("session_started_at_ms"), Value: h.SessionStartedAtMs, }, clause.Lte{ - Column: clause.Column{Name: "sequence"}, + Column: historyColumn("sequence"), Value: h.Sequence, }, ), diff --git a/internal/repository/history_repository_dialect_test.go b/internal/repository/history_repository_dialect_test.go new file mode 100644 index 0000000..1c7963c --- /dev/null +++ b/internal/repository/history_repository_dialect_test.go @@ -0,0 +1,65 @@ +package repository + +import ( + "context" + "strings" + "testing" + + "github.com/truewhile/MeBox/internal/model" +) + +// TestHistoryProgressUpsertQualifiesConflictWhereColumns 守住 PostgreSQL 下进度上报 +// 的 upsert 语句合法性。 +// +// ON CONFLICT DO UPDATE 的 WHERE 里如果不给列名加表限定,PostgreSQL 会因为这些列 +// 同时出现在本次插入的行里而拒绝该语句: +// +// column reference "session_started_at_ms" is ambiguous (SQLSTATE 42702) +// +// 后果是 POST /api/history 全部 500,观看进度与续播完全存不下来。SQLite 不做这项 +// 检查(旧写法在 SQLite 下一直正常),所以只能在 PostgreSQL 方言下断言。 +func TestHistoryProgressUpsertQualifiesConflictWhereColumns(t *testing.T) { + repos, rec := newPostgresDryRunRepos(t) + ctx := context.Background() + + if err := repos.History.UpsertProgress(ctx, &model.PlaybackHistory{ + UserID: "u-1", + MediaID: "m-1", + PositionMs: 1_000, + DurationMs: 2_000, + SessionID: "session-1", + SessionStartedAtMs: 111, + Sequence: 2, + }); err != nil { + t.Fatalf("build upsert: %v", err) + } + + sql := rec.all() + if strings.TrimSpace(sql) == "" { + t.Fatal("no SQL captured from UpsertProgress") + } + if !strings.Contains(sql, "ON CONFLICT") { + t.Fatalf("expected an ON CONFLICT upsert, got:\n%s", sql) + } + + // 只看 ON CONFLICT 之后的部分:SET 的赋值目标和 CASE 里的 duration_ms 引用 + // 属于另一套语法规则,不在这里断言。 + conflict := sql[strings.Index(sql, "ON CONFLICT"):] + whereIdx := strings.Index(conflict, " WHERE ") + if whereIdx < 0 { + t.Fatalf("ON CONFLICT clause has no DO UPDATE ... WHERE guard:\n%s", sql) + } + where := conflict[whereIdx:] + + for _, want := range []string{ + `"playback_histories"."session_started_at_ms"`, + `"playback_histories"."sequence"`, + } { + if !strings.Contains(where, want) { + t.Fatalf("DO UPDATE WHERE is missing the table-qualified %s; it is ambiguous on PostgreSQL:\n%s", want, sql) + } + } + if strings.Contains(where, `("session_started_at_ms"`) || strings.Contains(where, ` AND "sequence"`) { + t.Fatalf("DO UPDATE WHERE still references unqualified columns:\n%s", sql) + } +} diff --git a/internal/repository/media_search_repository.go b/internal/repository/media_search_repository.go index d7772dd..63fed41 100644 --- a/internal/repository/media_search_repository.go +++ b/internal/repository/media_search_repository.go @@ -106,11 +106,8 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string, q := r.db.WithContext(ctx).Model(&model.Media{}) q = applyMediaQueryFilter(q, filter) // SQLite 的 LIKE 对 ASCII 不区分大小写;Postgres 的 LIKE 区分大小写, - // 需用 ILIKE 保持两端搜索行为一致。 - likeOp := "LIKE" - if r.db.Dialector != nil && r.db.Dialector.Name() == "postgres" { - likeOp = "ILIKE" - } + // 需用 ILIKE 保持两端搜索行为一致(见 dialect.go 的 likeOperator)。 + likeOp := likeOperator(r.db) terms := mediaSearchTerms(query) for _, term := range terms { like := "%" + escapeLike(term) + "%" diff --git a/internal/repository/media_segment_repository.go b/internal/repository/media_segment_repository.go index a63a6d0..7bd88c0 100644 --- a/internal/repository/media_segment_repository.go +++ b/internal/repository/media_segment_repository.go @@ -86,21 +86,15 @@ func (r *MediaSegmentRepository) UpsertFetch(ctx context.Context, row *model.Med return r.db.WithContext(ctx).Clauses(onConflict).Create(row).Error } -// ListPrewarmCandidates returns media that should be refreshed from source: -// no ledger, expired miss (fetched_at < missBefore), or expired hit (fetched_at < hitBefore). -// Recently played items come first so hot titles recover coverage sooner. -func (r *MediaSegmentRepository) ListPrewarmCandidates( - ctx context.Context, - source string, - missBefore, hitBefore time.Time, - limit int, -) ([]model.Media, error) { - if r == nil || limit <= 0 { - return nil, nil - } - rows := make([]model.Media, 0, limit) - // 可查询:有 TMDb,且是剧集(有季集)或电影(无季集)。 - err := r.db.WithContext(ctx).Raw(` +// segmentPrewarmCandidatesQuery 是片段预取候选查询。 +// +// 抽成包级常量是为了让方言相关的回归测试能直接断言语句文本:DryRun 模式下 +// GORM 不会把 Raw 语句交给 logger,无法从执行路径上捕获生成后的 SQL。 +// +// 注意 found 必须用占位符绑定布尔值,不能写 f.found = 0 / = 1: +// PostgreSQL 没有 boolean = integer 运算符,那样写会固定报 SQLSTATE 42883 +// (SQLite 宽容,所以只在 PostgreSQL 上暴露)。 +const segmentPrewarmCandidatesQuery = ` SELECT m.* FROM media m LEFT JOIN media_segment_fetches f @@ -119,11 +113,29 @@ WHERE m.deleted_at IS NULL ) AND ( f.id IS NULL - OR (f.found = 0 AND f.fetched_at < ?) - OR (f.found = 1 AND f.fetched_at < ?) + OR (f.found = ? AND f.fetched_at < ?) + OR (f.found = ? AND f.fetched_at < ?) ) ORDER BY ph.last_played DESC LIMIT ? -`, source, missBefore, hitBefore, limit).Scan(&rows).Error +` + +// ListPrewarmCandidates returns media that should be refreshed from source: +// no ledger, expired miss (fetched_at < missBefore), or expired hit (fetched_at < hitBefore). +// Recently played items come first so hot titles recover coverage sooner. +func (r *MediaSegmentRepository) ListPrewarmCandidates( + ctx context.Context, + source string, + missBefore, hitBefore time.Time, + limit int, +) ([]model.Media, error) { + if r == nil || limit <= 0 { + return nil, nil + } + rows := make([]model.Media, 0, limit) + // 可查询:有 TMDb,且是剧集(有季集)或电影(无季集)。 + err := r.db.WithContext(ctx). + Raw(segmentPrewarmCandidatesQuery, source, false, missBefore, true, hitBefore, limit). + Scan(&rows).Error return rows, err } diff --git a/internal/repository/reader_repository.go b/internal/repository/reader_repository.go index 34d2080..81b404b 100644 --- a/internal/repository/reader_repository.go +++ b/internal/repository/reader_repository.go @@ -384,9 +384,17 @@ func (r *ReaderRepository) CountChaptersByBook(ctx context.Context, bookIDs []st } // GetChapter 取指定章节。 +// +// index 在 PostgreSQL 里是保留字,必须让 GORM 按方言加引号(SQLite 反引号 / +// PostgreSQL 双引号)。此前写成硬编码的 "book_id = ? AND `index` = ?", +// PostgreSQL 会把反引号词法分析成运算符并报 syntax error at or near "`"。 func (r *ReaderRepository) GetChapter(ctx context.Context, bookID string, index int) (*model.ReaderChapter, error) { var out model.ReaderChapter - if err := r.db.WithContext(ctx).First(&out, "book_id = ? AND `index` = ?", bookID, index).Error; err != nil { + err := r.db.WithContext(ctx). + Where(clause.Eq{Column: clause.Column{Name: "book_id"}, Value: bookID}). + Where(clause.Eq{Column: clause.Column{Name: "index"}, Value: index}). + First(&out).Error + if err != nil { return nil, err } return &out, nil diff --git a/internal/repository/reader_repository_dialect_test.go b/internal/repository/reader_repository_dialect_test.go index 46d279d..af0689c 100644 --- a/internal/repository/reader_repository_dialect_test.go +++ b/internal/repository/reader_repository_dialect_test.go @@ -38,16 +38,19 @@ func (l *sqlCaptureLogger) all() string { // newPostgresDryRunRepos 返回使用 PostgreSQL 方言的 repository 容器: // DryRun 只构建 SQL 不执行,DisableAutomaticPing 让它无需真实数据库也能离线运行, -// 于是可以直接断言生成语句在 PostgreSQL 下是否合法。 +// SkipDefaultTransaction 则挡掉 GORM 默认事务包装——那一步会真的去开事务连接, +// 在 DryRun 下同样会拨号(ToSQL 内部也是这么绕开的)。于是可以直接断言生成语句 +// 在 PostgreSQL 下是否合法。 func newPostgresDryRunRepos(t *testing.T) (*Container, *sqlCaptureLogger) { t.Helper() rec := &sqlCaptureLogger{} db, err := gorm.Open(postgres.New(postgres.Config{ DSN: "postgres://mebox:mebox@127.0.0.1:5432/mebox?sslmode=disable", }), &gorm.Config{ - DryRun: true, - DisableAutomaticPing: true, - Logger: rec, + DryRun: true, + DisableAutomaticPing: true, + SkipDefaultTransaction: true, + Logger: rec, }) if err != nil { t.Fatalf("open dry-run postgres: %v", err) diff --git a/internal/service/backup.go b/internal/service/backup.go index 6e1d071..0585a9e 100644 --- a/internal/service/backup.go +++ b/internal/service/backup.go @@ -1,9 +1,13 @@ // Package service — database hot backup and restore. // -// BackupService uses SQLite's "VACUUM INTO" (available since 3.27.0) to -// produce a consistent copy of the database without blocking writers. -// Backup files are stored under {data_dir}/backups/ with a timestamp -// suffix. The admin UI can list / download / delete / restore them. +// Backup files are stored under {data_dir}/backups/ with a timestamp suffix. +// The admin UI can list / download / delete / restore them. +// +// 两种方言的备份形态不同: +// - SQLite:用 "VACUUM INTO"(3.27.0 起可用)产出一个一致的库文件, +// 扩展名 .db;恢复即「替换库文件 + 重启」。 +// - PostgreSQL:没有单文件语义,用逻辑备份(见 backup_postgres.go), +// 扩展名 .json.gz;恢复是在单个事务内重建全部表。 package service import ( @@ -65,18 +69,34 @@ func (b *BackupService) backupFilePath(filename string) (string, error) { return path, nil } -// Create produces a new hot backup via "VACUUM INTO". +// isPostgres reports whether the service is backed by PostgreSQL. +// +// PostgreSQL 不支持 SQLite 的 "VACUUM INTO",按文件替换恢复也会写错目标, +// 因此备份/恢复必须按方言分支,不能共用一条路径。 +func (b *BackupService) isPostgres() bool { + return b.db != nil && b.db.Dialector != nil && b.db.Dialector.Name() == "postgres" +} + +// Create produces a new hot backup. SQLite uses "VACUUM INTO"; PostgreSQL uses +// a logical dump. func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) { dir := b.backupDir() if err := os.MkdirAll(dir, 0o750); err != nil { return nil, err } ts := time.Now().Format("20060102_150405") - dst := filepath.Join(dir, fmt.Sprintf("mebox_%s.db", ts)) - // VACUUM INTO creates a clean, non-WAL copy atomically. - if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", dst).Error; err != nil { - return nil, fmt.Errorf("VACUUM INTO failed: %w", err) + var ( + dst string + err error + ) + if b.isPostgres() { + dst, err = b.createPostgresBackup(ctx, ts) + } else { + dst, err = b.createSQLiteBackup(ctx, ts) + } + if err != nil { + return nil, err } stat, err := os.Stat(dst) if err != nil { @@ -91,6 +111,15 @@ func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) { }, 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 { + return "", fmt.Errorf("VACUUM INTO failed: %w", err) + } + return dst, nil +} + // List returns every backup file in the backup directory, newest first. func (b *BackupService) List() ([]BackupInfo, error) { dir := b.backupDir() @@ -103,7 +132,7 @@ func (b *BackupService) List() ([]BackupInfo, error) { } out := make([]BackupInfo, 0, len(entries)) for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") { + if e.IsDir() || !isBackupFilename(e.Name()) { continue } info, err := e.Info() @@ -132,9 +161,10 @@ func (b *BackupService) Delete(filename string) error { return os.Remove(path) } -// Restore copies a backup over the live database using VACUUM INTO in -// reverse. WARNING: this is destructive — the live DB will be replaced. -// Callers should shut down the server after this call. +// 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. func (b *BackupService) Restore(ctx context.Context, filename string) error { src, err := b.backupFilePath(filename) if err != nil { @@ -143,6 +173,19 @@ func (b *BackupService) Restore(ctx context.Context, filename string) error { if _, err := os.Stat(src); err != nil { return fmt.Errorf("backup not found: %s", filename) } + if b.isPostgres() { + if !strings.HasSuffix(filename, postgresBackupSuffix) { + return errors.New("该备份是 SQLite 文件备份,当前运行在 PostgreSQL 上,无法恢复") + } + return b.restorePostgresBackup(ctx, src) + } + 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") @@ -171,7 +214,13 @@ func (b *BackupService) Restore(ctx context.Context, filename string) error { return nil } -// isValidBackupFilename rejects path traversal attempts and non-.db files. +// isBackupFilename reports whether name looks like a backup produced by either +// dialect (SQLite ".db" or PostgreSQL ".json.gz"). +func isBackupFilename(name string) bool { + return strings.HasSuffix(name, ".db") || strings.HasSuffix(name, postgresBackupSuffix) +} + +// isValidBackupFilename rejects path traversal attempts and non-backup files. func isValidBackupFilename(name string) bool { if name == "" { return false @@ -179,8 +228,5 @@ func isValidBackupFilename(name string) bool { if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") { return false } - if !strings.HasSuffix(name, ".db") { - return false - } - return true + return isBackupFilename(name) } diff --git a/internal/service/backup_postgres.go b/internal/service/backup_postgres.go new file mode 100644 index 0000000..0dd6db7 --- /dev/null +++ b/internal/service/backup_postgres.go @@ -0,0 +1,279 @@ +package service + +import ( + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/schema" + + "github.com/truewhile/MeBox/internal/model" +) + +// PostgreSQL 逻辑备份 / 恢复。 +// +// SQLite 的备份是「VACUUM INTO 出一个一致的库文件,恢复时替换文件」,而 +// PostgreSQL 既没有 VACUUM INTO,数据也由服务端进程管理,没有可替换的单文件, +// 所以这里实现逻辑备份:把每张表按主键顺序分块序列化成 JSON Lines(gzip 压缩) +// 写入 {data_dir}/backups/。 +// +// 分块序列化而不是整表 marshal:media 表在真实媒体库上可达几十万行、每行还带 +// overview / path 等长文本,整表进内存会把小内存的 NAS 打爆。 +// +// 恢复在单个事务内「先清空全部模型表、再逐块插入」:任何一步失败(包括备份文件 +// 读到一半损坏)都整体回滚,不会留下半清空的库。因此恢复不需要重启服务。 +const ( + postgresBackupSuffix = ".json.gz" + postgresBackupFormat = "mebox-postgres-logical" + postgresBackupVersion = 1 + backupDumpChunkSize = 1000 + backupRestoreBatchSize = 500 +) + +type postgresBackupHeader struct { + Format string `json:"format"` + Version int `json:"version"` + CreatedAt time.Time `json:"created_at"` +} + +// postgresBackupRow 是「列名 → JSON 值」的一行。 +// +// 刻意逐列反射序列化,而不是直接 json.Marshal 模型结构体:模型里有大量带 +// json:"-" 的列(Base.DeletedAt、User.AllowedLibraryIDs、APIConfig.APIKey、 +// StrmAccount.Config、PlayProfile.PINHash、ReaderBookSource.RawJSON…),走结构体 +// 自带的序列化会把这些列整列丢掉——软删除行会在恢复后「复活」,加密后的第三方 +// 密钥与 PIN 会全部丢失。 +type postgresBackupRow map[string]json.RawMessage + +type postgresBackupChunk struct { + Table string `json:"table"` + Rows []postgresBackupRow `json:"rows"` +} + +// parseModelSchema 返回模型对应的表名与 GORM schema(逐列反射所需)。 +func parseModelSchema(db *gorm.DB, m any) (table string, sch *schema.Schema, err error) { + stmt := &gorm.Statement{DB: db} + if err := stmt.Parse(m); err != nil { + return "", nil, err + } + return stmt.Schema.Table, stmt.Schema, nil +} + +// postgresBackupModelSchemas 返回「表名 → 模型 schema / 结构体类型」,用于把备份 +// 里的行还原成 GORM 能插入的具体类型。 +func postgresBackupModelSchemas(db *gorm.DB) (map[string]*schema.Schema, map[string]reflect.Type, error) { + schemas := make(map[string]*schema.Schema, len(model.AllModels())) + types := make(map[string]reflect.Type, len(model.AllModels())) + for _, m := range model.AllModels() { + table, sch, err := parseModelSchema(db, m) + if err != nil { + return nil, nil, err + } + schemas[table] = sch + types[table] = reflect.TypeOf(m).Elem() + } + return schemas, types, nil +} + +// 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) + if err != nil { + return "", err + } + complete := false + defer func() { + _ = file.Close() + if !complete { + // 半截备份文件比没有备份更危险:留着会让人以为有可用的还原点。 + _ = os.Remove(dst) + } + }() + + zw := gzip.NewWriter(file) + enc := json.NewEncoder(zw) + if err := enc.Encode(postgresBackupHeader{ + Format: postgresBackupFormat, + Version: postgresBackupVersion, + CreatedAt: time.Now(), + }); 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 + } + } + if err := zw.Close(); err != nil { + return "", fmt.Errorf("压缩备份失败: %w", err) + } + if err := file.Sync(); err != nil { + return "", fmt.Errorf("写入备份失败: %w", err) + } + complete = true + return dst, nil +} + +// dumpPostgresTable 按主键分块导出一张表。FindInBatches 会自己按主键升序游标 +// 翻页(WHERE pk > last),不会像 OFFSET 分页那样在大表上做 O(n²) 扫描。 +func (b *BackupService) dumpPostgresTable(ctx context.Context, m any, sch *schema.Schema, table string, enc *json.Encoder) error { + // 软删除行也要备份:恢复后应还原成删除前的样子,而不是让它们「复活」。 + slicePtr := reflect.New(reflect.SliceOf(reflect.TypeOf(m).Elem())) + err := b.db.WithContext(ctx).Unscoped(). + FindInBatches(slicePtr.Interface(), backupDumpChunkSize, func(_ *gorm.DB, _ int) error { + rows, err := encodePostgresBackupRows(ctx, sch, slicePtr.Elem()) + if err != nil { + return fmt.Errorf("编码表 %s 失败: %w", table, err) + } + return enc.Encode(postgresBackupChunk{Table: table, Rows: rows}) + }).Error + if err != nil { + return fmt.Errorf("导出表 %s 失败: %w", table, err) + } + return nil +} + +// backupPersistableFields 返回真正对应数据库列的字段。 +// +// GORM 的 Schema.Fields 里还带着 gorm:"-" 的字段(例如 User.AllowedLibraryList、 +// LibraryTagList),它们的 DBName 是空字符串。不过滤的话多个「列」会共用空键: +// 编码时互相覆盖,解码时把某个字段的值写到另一个字段上,报出 +// 「cannot unmarshal number into Go value of type []string」这类与真实数据无关的错。 +func backupPersistableFields(sch *schema.Schema) []*schema.Field { + out := make([]*schema.Field, 0, len(sch.Fields)) + for _, field := range sch.Fields { + if field.DBName == "" { + continue + } + out = append(out, field) + } + return out +} + +// encodePostgresBackupRows 逐列反射序列化一个批次。 +func encodePostgresBackupRows(ctx context.Context, sch *schema.Schema, slice reflect.Value) ([]postgresBackupRow, error) { + fields := backupPersistableFields(sch) + rows := make([]postgresBackupRow, 0, slice.Len()) + for i := 0; i < slice.Len(); i++ { + element := slice.Index(i) + row := make(postgresBackupRow, len(fields)) + for _, field := range fields { + value := field.ReflectValueOf(ctx, element) + if !value.IsValid() { + continue + } + raw, err := json.Marshal(value.Interface()) + if err != nil { + return nil, fmt.Errorf("列 %s: %w", field.DBName, err) + } + row[field.DBName] = raw + } + rows = append(rows, row) + } + return rows, nil +} + +// decodePostgresBackupRows 把「列名 → JSON 值」还原成可直接插入的具体切片。 +func decodePostgresBackupRows(ctx context.Context, sch *schema.Schema, modelType reflect.Type, rows []postgresBackupRow) (any, error) { + fields := backupPersistableFields(sch) + slice := reflect.MakeSlice(reflect.SliceOf(modelType), len(rows), len(rows)) + for i, row := range rows { + element := slice.Index(i) + for _, field := range fields { + raw, ok := row[field.DBName] + if !ok { + continue + } + holder := reflect.New(field.FieldType) + if err := json.Unmarshal(raw, holder.Interface()); err != nil { + return nil, fmt.Errorf("列 %s: %w", field.DBName, err) + } + if err := field.Set(ctx, element, holder.Elem().Interface()); err != nil { + return nil, fmt.Errorf("列 %s: %w", field.DBName, err) + } + } + } + return slice.Interface(), nil +} + +// restorePostgresBackup 用逻辑备份重建全库。 +func (b *BackupService) restorePostgresBackup(ctx context.Context, src string) error { + file, err := os.Open(src) + if err != nil { + return err + } + defer file.Close() + zr, err := gzip.NewReader(file) + if err != nil { + return fmt.Errorf("备份文件不是有效的 gzip: %w", err) + } + defer zr.Close() + + dec := json.NewDecoder(zr) + var header postgresBackupHeader + if err := dec.Decode(&header); err != nil { + return fmt.Errorf("备份头损坏: %w", err) + } + if header.Format != postgresBackupFormat || header.Version != postgresBackupVersion { + return fmt.Errorf("不支持的备份格式 %q (v%d)", header.Format, header.Version) + } + schemas, modelTypes, err := postgresBackupModelSchemas(b.db) + if err != nil { + return err + } + + // 恢复必须在单个事务里完成:清空成功后中途失败若不回滚,库就空了。 + return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 逆序清空,保证 library_roots → libraries 这类外键先删子表。 + models := model.AllModels() + for i := len(models) - 1; i >= 0; i-- { + m := models[i] + if !tx.Migrator().HasTable(m) { + continue + } + if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(m).Error; err != nil { + return fmt.Errorf("清空表失败: %w", err) + } + } + for { + var chunk postgresBackupChunk + decodeErr := dec.Decode(&chunk) + if errors.Is(decodeErr, io.EOF) { + return nil + } + if decodeErr != nil { + return fmt.Errorf("备份文件损坏: %w", decodeErr) + } + sch, ok := schemas[chunk.Table] + if !ok { + return fmt.Errorf("备份包含未知表 %s,可能来自不兼容的版本", chunk.Table) + } + if len(chunk.Rows) == 0 { + continue + } + rows, err := decodePostgresBackupRows(ctx, sch, modelTypes[chunk.Table], chunk.Rows) + if err != nil { + return fmt.Errorf("表 %s 解码失败: %w", chunk.Table, err) + } + if err := tx.Unscoped().CreateInBatches(rows, backupRestoreBatchSize).Error; err != nil { + return fmt.Errorf("恢复表 %s 失败: %w", chunk.Table, err) + } + } + }) +} diff --git a/internal/service/backup_postgres_test.go b/internal/service/backup_postgres_test.go new file mode 100644 index 0000000..cbd6ec4 --- /dev/null +++ b/internal/service/backup_postgres_test.go @@ -0,0 +1,226 @@ +package service + +import ( + "compress/gzip" + "context" + "os" + "path/filepath" + "reflect" + "testing" + + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" +) + +func newBackupTestService(t *testing.T) (*BackupService, *config.Config) { + t.Helper() + cfg := &config.Config{} + cfg.App.DataDir = t.TempDir() + db := newServiceTestDB(t) + svc := &BackupService{cfg: cfg, log: zap.NewNop(), db: db} + // Create 会先建目录;直接调各方言的 create* 助手时需要它已存在。 + if err := os.MkdirAll(svc.backupDir(), 0o750); err != nil { + t.Fatal(err) + } + return svc, cfg +} + +// TestPostgresLogicalBackupRoundTrip 验证「备份 → 改库 → 恢复」后整库回到备份时的 +// 样子,包括软删除行与 json:"-" 的列。 +// +// 逻辑备份/恢复本身是方言无关的 GORM 代码(PostgreSQL 专有的只有文件后缀与 +// isPostgres() 分派),所以可以直接在 SQLite 上跑。 +func TestPostgresLogicalBackupRoundTrip(t *testing.T) { + svc, _ := newBackupTestService(t) + ctx := context.Background() + db := svc.db + + lib := model.Library{Base: model.Base{ID: "lib-1"}, Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true} + if err := db.Create(&lib).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.LibraryRoot{Base: model.Base{ID: "root-1"}, LibraryID: lib.ID, Path: "/media/movies"}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.Media{Base: model.Base{ID: "m-keep"}, LibraryID: lib.ID, Title: "Keep", Path: "/media/movies/keep.mkv"}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.Media{Base: model.Base{ID: "m-gone"}, LibraryID: lib.ID, Title: "Gone", Path: "/media/movies/gone.mkv"}).Error; err != nil { + t.Fatal(err) + } + if err := db.Delete(&model.Media{}, "id = ?", "m-gone").Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.Setting{Key: "k", Value: "v"}).Error; err != nil { + t.Fatal(err) + } + // 下面这些列都带 json:"-":走结构体自带的 JSON 序列化会被整列丢掉, + // 恢复之后用户权限、置顶库、加密后的密钥会一起消失。 + user := model.User{ + Base: model.Base{ID: "u-1"}, + Username: "alice", + PasswordHash: "hash", + Role: "user", + AllowedLibraryIDs: `["lib-1"]`, + PinnedLibraryIDs: `["lib-1"]`, + HideAdult: true, + IsActive: true, + DanmakuEnabled: true, + PlayerVr360GuideSeen: true, + } + if err := db.Create(&user).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.APIConfig{Base: model.Base{ID: "cfg-1"}, Provider: "tmdb", APIKey: "ciphertext", Enabled: true}).Error; err != nil { + t.Fatal(err) + } + + dump, err := svc.createPostgresBackup(ctx, "20260101_000000") + if err != nil { + t.Fatalf("createPostgresBackup: %v", err) + } + if dump == "" { + t.Fatal("createPostgresBackup returned an empty path") + } + + // 破坏当前数据:加一条备份里没有的、硬删一条备份里有的。 + if err := db.Create(&model.Media{Base: model.Base{ID: "m-extra"}, LibraryID: lib.ID, Title: "Extra", Path: "/media/movies/extra.mkv"}).Error; err != nil { + t.Fatal(err) + } + if err := db.Unscoped().Delete(&model.Media{}, "id = ?", "m-keep").Error; err != nil { + t.Fatal(err) + } + + if err := svc.restorePostgresBackup(ctx, dump); err != nil { + t.Fatalf("restorePostgresBackup: %v", err) + } + + var ids []string + if err := db.Unscoped().Model(&model.Media{}).Order("id asc").Pluck("id", &ids).Error; err != nil { + t.Fatal(err) + } + if want := []string{"m-gone", "m-keep"}; !reflect.DeepEqual(ids, want) { + t.Fatalf("media ids after restore = %v, want %v", ids, want) + } + + // 软删除状态必须原样还原,而不是被「复活」。 + var gone model.Media + if err := db.Unscoped().First(&gone, "id = ?", "m-gone").Error; err != nil { + t.Fatal(err) + } + if !gone.DeletedAt.Valid { + t.Fatal("soft-deleted media was revived by restore") + } + var live int64 + if err := db.Model(&model.Media{}).Where("id = ?", "m-keep").Count(&live).Error; err != nil { + t.Fatal(err) + } + if live != 1 { + t.Fatalf("live media count = %d, want 1", live) + } + + var value string + if err := db.Model(&model.Setting{}).Where("key = ?", "k").Select("value").Scan(&value).Error; err != nil { + t.Fatal(err) + } + if value != "v" { + t.Fatalf("setting value = %q, want v", value) + } + var libCount int64 + if err := db.Model(&model.Library{}).Count(&libCount).Error; err != nil { + t.Fatal(err) + } + if libCount != 1 { + t.Fatalf("library count = %d, want 1", libCount) + } + var rootCount int64 + if err := db.Model(&model.LibraryRoot{}).Count(&rootCount).Error; err != nil { + t.Fatal(err) + } + if rootCount != 1 { + t.Fatalf("library root count = %d, want 1", rootCount) + } + + // json:"-" 的列必须同样被备份:它们是权限与凭据,丢了比丢一条媒体严重得多。 + var restoredUser model.User + if err := db.First(&restoredUser, "id = ?", "u-1").Error; err != nil { + t.Fatal(err) + } + if restoredUser.PasswordHash != "hash" { + t.Fatalf("password hash = %q, want hash", restoredUser.PasswordHash) + } + if restoredUser.AllowedLibraryIDs != `["lib-1"]` { + t.Fatalf(`allowed library ids = %q, want ["lib-1"]`, restoredUser.AllowedLibraryIDs) + } + if restoredUser.PinnedLibraryIDs != `["lib-1"]` { + t.Fatalf(`pinned library ids = %q, want ["lib-1"]`, restoredUser.PinnedLibraryIDs) + } + if !restoredUser.HideAdult || !restoredUser.IsActive || !restoredUser.DanmakuEnabled || !restoredUser.PlayerVr360GuideSeen { + t.Fatalf("boolean columns lost: %+v", restoredUser) + } + + var restoredConfig model.APIConfig + if err := db.First(&restoredConfig, "id = ?", "cfg-1").Error; err != nil { + t.Fatal(err) + } + if restoredConfig.APIKey != "ciphertext" { + t.Fatalf("api key = %q, want ciphertext", restoredConfig.APIKey) + } +} + +// TestRestorePostgresBackupKeepsDataOnCorruptFile 验证损坏的备份不会在回滚后留下 +// 半清空的库:恢复是先清空再插入,必须整体在一个事务里,否则中途失败就把库清掉了。 +func TestRestorePostgresBackupKeepsDataOnCorruptFile(t *testing.T) { + svc, _ := newBackupTestService(t) + ctx := context.Background() + if err := svc.db.Create(&model.Setting{Key: "keep", Value: "me"}).Error; err != nil { + t.Fatal(err) + } + + corrupt := filepath.Join(t.TempDir(), "broken.json.gz") + file, err := os.Create(corrupt) + if err != nil { + t.Fatal(err) + } + zw := gzip.NewWriter(file) + // 头部完整、正文半截:解码在清空表之后才会失败。 + if _, err := zw.Write([]byte( + `{"format":"mebox-postgres-logical","version":1,"created_at":"2026-01-01T00:00:00Z"}` + "\n" + + `{"table":"media","rows":[`)); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + + if err := svc.restorePostgresBackup(ctx, corrupt); err == nil { + t.Fatal("corrupt backup should fail to restore") + } + + var value string + if err := svc.db.Model(&model.Setting{}).Where("key = ?", "keep").Select("value").Scan(&value).Error; err != nil { + t.Fatal(err) + } + if value != "me" { + t.Fatalf("a failed restore wiped the database: settings keep = %q", value) + } +} + +// TestRestoreRejectsBackupFromOtherDialect 验证备份文件与当前方言不匹配时给出明确 +// 错误,而不是把 SQLite 文件写到 PostgreSQL 部署的库路径上(那是原实现的隐患)。 +func TestRestoreRejectsBackupFromOtherDialect(t *testing.T) { + svc, _ := newBackupTestService(t) + pgStyle := filepath.Join(svc.backupDir(), "mebox_20260101_000000.json.gz") + if err := os.WriteFile(pgStyle, []byte("not-gzip"), 0o600); err != nil { + t.Fatal(err) + } + // SQLite 路径下不允许恢复 PostgreSQL 逻辑备份。 + if err := svc.Restore(context.Background(), filepath.Base(pgStyle)); err == nil { + t.Fatal("SQLite restore accepted a PostgreSQL logical backup") + } +} diff --git a/internal/service/emby_episodic_path_test.go b/internal/service/emby_episodic_path_test.go new file mode 100644 index 0000000..99bdb1b --- /dev/null +++ b/internal/service/emby_episodic_path_test.go @@ -0,0 +1,82 @@ +package service + +import ( + "strings" + "testing" + + "github.com/truewhile/MeBox/internal/model" +) + +// TestLikelyEpisodicPathSQLNormalisesSeparator 守住剧集路径判定的转义差异。 +// +// 此前 embyLikelyEpisodicPathSQL 会额外生成一份反斜杠模式 +// (strings.ReplaceAll(pattern, "/", `\`))。PostgreSQL 的 LIKE 默认就把反斜杠 +// 当转义符,模式结尾的 `\%` 被解析成「字面百分号」而不是「反斜杠 + 通配符」, +// 那些模式在 PostgreSQL 下永不命中;SQLite 没有默认转义符所以一直正常。 +// 现在统一在 SQL 里把分隔符归一化成 '/',模式本身不再含反斜杠。 +func TestLikelyEpisodicPathSQLNormalisesSeparator(t *testing.T) { + clause, args := embyLikelyEpisodicPathSQL() + if !strings.Contains(clause, "REPLACE(media.path") { + t.Fatalf("clause no longer normalises the path separator:\n%s", clause) + } + if got := strings.Count(clause, "LIKE ?"); got != len(args) { + t.Fatalf("clause has %d placeholders but %d args:\n%s", got, len(args), clause) + } + for _, arg := range args { + pattern, ok := arg.(string) + if !ok { + t.Fatalf("unexpected pattern type %T", arg) + } + if strings.Contains(pattern, `\`) { + t.Fatalf("pattern %q still contains a backslash; PostgreSQL's LIKE would treat it as an escape character", pattern) + } + if !strings.Contains(pattern, "/") { + t.Fatalf("pattern %q lost its separator", pattern) + } + } +} + +// TestFilterLikelyEpisodicPathsHandlesWindowsStylePaths 验证反斜杠路径(Windows +// 宿主机入库的媒体)仍然被判成剧集并从电影库查询里排除。 +func TestFilterLikelyEpisodicPathsHandlesWindowsStylePaths(t *testing.T) { + db := newServiceTestDB(t) + episodic := model.Media{ + Base: model.Base{ID: "ep-1"}, + Path: `D:\Media\国产剧\我的剧\Season 1\我的剧 - S01E01.mkv`, + SeasonNum: 1, + EpisodeNum: 1, + } + movie := model.Media{ + Base: model.Base{ID: "mv-1"}, + Path: `D:\Media\电影\某电影\某电影.mkv`, + } + if err := db.Create(&episodic).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&movie).Error; err != nil { + t.Fatal(err) + } + + q := filterLikelyEpisodicPathsFromMovieQuery( + db.Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0"), + ) + var rows []model.Media + if err := q.Find(&rows).Error; err != nil { + t.Fatal(err) + } + if len(rows) != 0 { + t.Fatalf("windows-style episodic path was not excluded, got %d rows", len(rows)) + } + + // 反证:电影类型的行必须保留。 + var kept []model.Media + keepQ := filterLikelyEpisodicPathsFromMovieQuery( + db.Model(&model.Media{}).Where("path LIKE ?", `D:\Media\电影\%`), + ) + if err := keepQ.Find(&kept).Error; err != nil { + t.Fatal(err) + } + if len(kept) != 1 || kept[0].ID != "mv-1" { + t.Fatalf("plain movie row was dropped from the movie query: %+v", kept) + } +} diff --git a/internal/service/emby_items_list.go b/internal/service/emby_items_list.go index 9a74e77..3114e60 100644 --- a/internal/service/emby_items_list.go +++ b/internal/service/emby_items_list.go @@ -7,6 +7,7 @@ import ( "time" "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string]any, error) { @@ -21,7 +22,11 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string q = q.Where("library_id IN ? OR series_id = ?", e.mergedLibraryIDs(ctx, p.ParentID), p.ParentID) } if p.SearchTerm != "" { - q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%") + // ILIKE(PostgreSQL)/ LIKE(SQLite)由方言决定,避免换库后搜索词 + // 大小写不匹配就搜不到结果。 + likeOp := repository.LikeOperator(e.repo.DB) + term := "%" + p.SearchTerm + "%" + q = q.Where("title "+likeOp+" ? OR original_name "+likeOp+" ?", term, term) } if containsEmbyFilter(p.Filters, "IsFavorite") { if strings.TrimSpace(p.UserID) == "" { @@ -264,7 +269,11 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin q = q.Where("library_id IN ?", e.mergedLibraryIDs(ctx, libraryID)) } if p.SearchTerm != "" { - q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%") + // ILIKE(PostgreSQL)/ LIKE(SQLite)由方言决定,避免换库后搜索词 + // 大小写不匹配就搜不到结果。 + likeOp := repository.LikeOperator(e.repo.DB) + term := "%" + p.SearchTerm + "%" + q = q.Where("title "+likeOp+" ? OR original_name "+likeOp+" ?", term, term) } if containsEmbyFilter(p.Filters, "IsFavorite") { if strings.TrimSpace(p.UserID) == "" { diff --git a/internal/service/emby_movie_items.go b/internal/service/emby_movie_items.go index f55bf44..a02b777 100644 --- a/internal/service/emby_movie_items.go +++ b/internal/service/emby_movie_items.go @@ -9,6 +9,7 @@ import ( "gorm.io/gorm" "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // movieLibraryHasEpisodicContent 报告电影类型库里是否混入了「剧集结构」内容 @@ -51,7 +52,11 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map q = e.applyUserMediaVisibility(ctx, q, p.UserID) q = q.Where("library_id IN ?", libIDs) if p.SearchTerm != "" { - q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%") + // 方言相关的 ILIKE/LIKE:PostgreSQL 的 LIKE 区分大小写, + // 直接用 LIKE 会让客户端搜索的大小写与入库标题不一致时搜不到。 + likeOp := repository.LikeOperator(e.repo.DB) + term := "%" + p.SearchTerm + "%" + q = q.Where("title "+likeOp+" ? OR original_name "+likeOp+" ?", term, term) } if containsEmbyFilter(p.Filters, "IsFavorite") { if strings.TrimSpace(p.UserID) == "" { @@ -266,6 +271,14 @@ func filterLikelyEpisodicPathsFromMovieQuery(q *gorm.DB) *gorm.DB { return q.Where("NOT ((media.season_num > 0 OR media.episode_num > 0) AND ("+clause+"))", args...) } +// embyEpisodicPathExpr 把路径分隔符归一化成 '/' 后再小写化,供剧集路径判定使用。 +// +// 反斜杠归一化必须放在 SQL 里做,而不是再生成一份反斜杠模式:PostgreSQL 的 +// LIKE 默认就把反斜杠当转义符,模式结尾的 `\%` 会被解析成「字面百分号」而不是 +// 「反斜杠 + 通配符」,那些模式在 PostgreSQL 下永不命中(SQLite 没有默认转义符, +// 所以只有换库之后才会暴露)。归一化列值后两种方言结果一致。 +const embyEpisodicPathExpr = `LOWER(REPLACE(media.path, '\', '/'))` + func embyLikelyEpisodicPathSQL() (string, []any) { patterns := []string{ "%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%", @@ -274,15 +287,11 @@ func embyLikelyEpisodicPathSQL() (string, []any) { "%/电视剧/%", "%/剧集/%", "%/连续剧/%", "%/短剧/%", "%/国产剧/%", "%/国剧/%", "%/大陆剧/%", "%/华语剧/%", "%/国产电视剧/%", "%/大陆电视剧/%", "%/华语电视剧/%", "%/欧美剧/%", "%/欧美电视剧/%", "%/美剧/%", "%/英剧/%", "%/日韩剧/%", "%/日韩电视剧/%", "%/日剧/%", "%/韩剧/%", "%/港剧/%", "%/台剧/%", "%/港台剧/%", "%/泰剧/%", "%/日番/%", "%/国漫/%", "%/番剧/%", "%/动漫/%", "%/特别篇/%", "%/特別篇/%", "%/番外/%", "%/特典/%", } - clauses := make([]string, 0, len(patterns)*2) - args := make([]any, 0, len(patterns)*2) + clauses := make([]string, 0, len(patterns)) + args := make([]any, 0, len(patterns)) for _, pattern := range patterns { - clauses = append(clauses, "LOWER(media.path) LIKE ?") + clauses = append(clauses, embyEpisodicPathExpr+" LIKE ?") args = append(args, pattern) - if strings.Contains(pattern, "/") { - clauses = append(clauses, "LOWER(media.path) LIKE ?") - args = append(args, strings.ReplaceAll(pattern, "/", `\`)) - } } return strings.Join(clauses, " OR "), args } diff --git a/internal/service/local_availability.go b/internal/service/local_availability.go index 98931a6..1940138 100644 --- a/internal/service/local_availability.go +++ b/internal/service/local_availability.go @@ -50,9 +50,12 @@ func LookupLocalAvailability(ctx context.Context, repo *repository.Container, ti return out } like := "%" + query + "%" + // 方言相关的 ILIKE/LIKE:PostgreSQL 下用 LIKE 会因标题大小写与查询不一致而 + // 漏判「本地已有该剧」,进而反复提示订阅缺失。 + likeOp := repository.LikeOperator(repo.DB) var rows []model.Media if err := repo.DB.WithContext(ctx). - Where("title LIKE ? OR original_name LIKE ? OR path LIKE ?", like, like, like). + Where("title "+likeOp+" ? OR original_name "+likeOp+" ? OR path "+likeOp+" ?", like, like, like). Order("season_num asc, episode_num asc, episode_fraction asc, created_at desc"). Limit(2000). Find(&rows).Error; err != nil { diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index 4a718e6..5ab3cac 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -1147,6 +1147,95 @@ export function PlayerPage() { mediaRef.current = media }, [media]) + /** + * 直连失败后的统一回退:按当前源与设置决定切 115 云转码 / 本地转码 / 明确报错。 + * error 分支与起播看门狗(armDirectStallWatchdog)共用同一套判定。 + */ + const fallbackDirectPlay = useCallback(() => { + if (modeRef.current !== 'direct') return + // 宽限期内又触发的迟到 fallback 定时器:直接丢掉。 + if (Date.now() < directSeekGraceUntilRef.current) return + const current = ref.current + if (current && current.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return + if (isRemoteEmbyID(mediaRef.current?.id) || isDirectStreamMedia(mediaRef.current)) { + setPlayerError('直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。') + toast.error('直接播放失败,建议使用外部播放器') + } else if (directOnly) { + setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。') + toast.error('直接播放失败(客户端直连解码模式)') + } else if (playbackInfo?.provider === 'cloud115') { + const preferred = + findPlaybackQualityById(playbackInfo, selectedQuality) ?? + findPlaybackQualityById(playbackInfo, playbackInfo.default_quality) + if (preferred?.source === 'cloud' && preferred.available) { + setCloudWaiting(false) + setHlsSource('cloud') + setPlaybackMode('hls') + } else if (preferred?.source === 'cloud') { + setHlsSource('cloud') + setCloudWaiting(true) + setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`) + void startCloudTranscode(Number(preferred.id) || 4) + } else { + switchToLocalHLS(current?.currentTime || 0) + } + } else if (hlsUnavailable) { + setPlayerError('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。') + toast.error('直接播放失败,HLS 转码不可用') + } else { + toast.error('直接播放失败,切换到 HLS 转码') + switchToLocalHLS(current?.currentTime || 0) + } + }, [ + directOnly, + hlsUnavailable, + playbackInfo, + selectedQuality, + setPlaybackMode, + startCloudTranscode, + switchToLocalHLS, + ]) + + /** + * 安排一次直连回退判定。STRM/115 直链冷启动比本地文件慢,给足缓冲时间再判失败。 + */ + const scheduleDirectFallback = useCallback(() => { + clearFallbackTimer() + const fallbackDelayMs = isStrmMedia(mediaRef.current) ? 4000 : 1500 + fallbackTimerRef.current = setTimeout(fallbackDirectPlay, fallbackDelayMs) + }, [clearFallbackTimer, fallbackDirectPlay]) + + // 看门狗定时器可能比一次渲染活得更久:始终调用最新一版回退逻辑,避免用 + // 「起播那一刻」捕获的 playbackInfo(那时可能还是 null)去判定,把本该走 + // 115 云转码的条目误判成本地转码。 + const fallbackDirectPlayRef = useRef<() => void>(() => {}) + useEffect(() => { + fallbackDirectPlayRef.current = fallbackDirectPlay + }, [fallbackDirectPlay]) + + /** + * 直连起播看门狗。 + * + * 直连没有 hls.js 那种 manifestLoadingTimeOut 兜底:请求挂住、或上游回了不带 + * error 事件的响应时,