mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-12 08:16:37 +08:00
优化兼容性
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
|
||||
@@ -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 密钥(不要提交值)。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) + "%"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+64
-18
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+109
-64
@@ -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 事件的响应时,<video> 会一直停在 readyState=0——不出画面、不报错、
|
||||
* 也不触发 handleVideoError,播放器就静默卡死(表现为「进去不自动播放」,
|
||||
* 只能退出重进或手动切播放方式)。这里给每次直连起播挂一个兜底定时器:
|
||||
* 到点仍是 readyState=0(连元数据都没解析到)就按失败处理;只要已经拿到
|
||||
* metadata 就说明链路是通的,只是慢,交给 playing 事件收尾。
|
||||
*/
|
||||
const armDirectStallWatchdog = useCallback(() => {
|
||||
clearFallbackTimer()
|
||||
const timeoutMs = isStrmMedia(mediaRef.current) ? 12000 : 6000
|
||||
fallbackTimerRef.current = setTimeout(() => {
|
||||
fallbackTimerRef.current = null
|
||||
if (modeRef.current !== 'direct') return
|
||||
if (Date.now() < directSeekGraceUntilRef.current) return
|
||||
const current = ref.current
|
||||
if (current && current.readyState >= HTMLMediaElement.HAVE_METADATA) return
|
||||
fallbackDirectPlayRef.current()
|
||||
}, timeoutMs)
|
||||
}, [clearFallbackTimer])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mediaId || !ref.current) return
|
||||
const currentMedia = mediaRef.current
|
||||
@@ -1297,6 +1386,8 @@ export function PlayerPage() {
|
||||
}
|
||||
video.src = url
|
||||
void video.play().catch(() => undefined)
|
||||
// 起播兜底:上游不回 error 时会一直停在 readyState=0,见 armDirectStallWatchdog。
|
||||
armDirectStallWatchdog()
|
||||
}
|
||||
if (hlsUnavailable && needsTranscodeForBrowser(currentMedia)) {
|
||||
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
|
||||
@@ -1311,6 +1402,7 @@ export function PlayerPage() {
|
||||
}
|
||||
}, [
|
||||
activeBurnedSubtitleStream,
|
||||
armDirectStallWatchdog,
|
||||
clearFallbackTimer,
|
||||
cloudReloadNonce,
|
||||
cloudWaiting,
|
||||
@@ -2174,16 +2266,21 @@ export function PlayerPage() {
|
||||
directRetryRef.current = true
|
||||
retryingDirectRef.current = true
|
||||
try {
|
||||
// STRM/115 直连:load() 会清空进度从头播。软恢复即可。
|
||||
if (isStrmMedia(mediaRef.current) || pendingDirectSeekRef.current !== null) {
|
||||
const pending = pendingDirectSeekRef.current
|
||||
void video.play().catch(() => undefined)
|
||||
if (pending !== null && pending > 1) {
|
||||
seekStrmDirectTo(pending)
|
||||
}
|
||||
} else {
|
||||
video.load()
|
||||
void video.play().catch(() => undefined)
|
||||
// 必须真正重新发起请求。媒体元素加载失败后 networkState 已经是
|
||||
// NETWORK_NO_SOURCE(不是 NETWORK_EMPTY),此时只调 play() 不会重跑资源
|
||||
// 选取算法:既不会重新请求,也不会再触发一次 error,于是没人去挂 fallback
|
||||
// 定时器——上游 403/502 一次就把播放器钉死在「direct + 空 src」上,没有
|
||||
// 错误提示也不会自动播放,只能退出重进或手动切播放方式。
|
||||
//
|
||||
// classifyDirectPlayError 只在 readyState < HAVE_CURRENT_DATA 时返回
|
||||
// 'retry',本来就没有可保住的播放进度,所以 load() 是安全的;需要续播
|
||||
// 的位置由 seekStrmDirectTo 在 metadata 就绪后补回。
|
||||
const resumeAt = pendingDirectSeekRef.current
|
||||
pendingDirectSeekRef.current = null
|
||||
video.load()
|
||||
void video.play().catch(() => undefined)
|
||||
if (resumeAt !== null && resumeAt > 1) {
|
||||
seekStrmDirectTo(resumeAt)
|
||||
}
|
||||
} finally {
|
||||
retryingDirectRef.current = false
|
||||
@@ -2191,60 +2288,8 @@ export function PlayerPage() {
|
||||
return
|
||||
}
|
||||
|
||||
const fallbackDirectPlay = () => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
clearFallbackTimer()
|
||||
// STRM/115 直链冷启动比本地文件慢,给足缓冲时间再判定失败。
|
||||
const fallbackDelayMs = isStrmMedia(mediaRef.current) ? 4000 : 1500
|
||||
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, fallbackDelayMs)
|
||||
}, [
|
||||
clearFallbackTimer,
|
||||
directOnly,
|
||||
hlsUnavailable,
|
||||
mediaId,
|
||||
mode,
|
||||
playbackInfo,
|
||||
seekStrmDirectTo,
|
||||
selectedQuality,
|
||||
setPlaybackMode,
|
||||
startCloudTranscode,
|
||||
switchToLocalHLS,
|
||||
])
|
||||
scheduleDirectFallback()
|
||||
}, [mediaId, mode, scheduleDirectFallback, seekStrmDirectTo])
|
||||
|
||||
const changeSubtitlePosition = useCallback((nextPosition: SubtitlePosition) => {
|
||||
setSubtitlePosition(nextPosition)
|
||||
|
||||
Reference in New Issue
Block a user