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))
|
a.logger.Error("database not ready", zap.Error(err))
|
||||||
return fmt.Errorf("database not ready: %w", 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))
|
a.logger.Error("auto-migrate failed", zap.Error(err))
|
||||||
return fmt.Errorf("auto-migrate failed: %w", 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.cache_size", -40000)
|
||||||
v.SetDefault("database.max_open_conns", defaultDatabaseMaxOpenConns)
|
v.SetDefault("database.max_open_conns", defaultDatabaseMaxOpenConns)
|
||||||
v.SetDefault("database.max_idle_conns", defaultDatabaseMaxIdleConns)
|
v.SetDefault("database.max_idle_conns", defaultDatabaseMaxIdleConns)
|
||||||
|
v.SetDefault("database.postgres_trgm_index", true)
|
||||||
|
|
||||||
v.SetDefault("secrets.jwt_secret", "")
|
v.SetDefault("secrets.jwt_secret", "")
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ type DatabaseConfig struct {
|
|||||||
CacheSize int `mapstructure:"cache_size"`
|
CacheSize int `mapstructure:"cache_size"`
|
||||||
MaxOpenConns int `mapstructure:"max_open_conns"`
|
MaxOpenConns int `mapstructure:"max_open_conns"`
|
||||||
MaxIdleConns int `mapstructure:"max_idle_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 密钥(不要提交值)。
|
// SecretsConfig 保存 JWT / 第三方 API 密钥(不要提交值)。
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ func MigrateCurrentToPostgres(src *gorm.DB, targetDSN string, batchSize int, log
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. 初始化目标库 Schema、类型与索引
|
// 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)
|
return nil, fmt.Errorf("auto migrate target PostgreSQL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AutoMigrate creates tables for every model registered in the model package.
|
// AutoMigrate creates tables for every model registered in the model package.
|
||||||
func AutoMigrate(db *gorm.DB) error {
|
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) 历史行,
|
// 必须先于 AutoMigrate:旧库中可能已有重复的 (user_id, media_id) 历史行,
|
||||||
// 不去重会导致唯一索引 uniq_user_history 创建失败。
|
// 不去重会导致唯一索引 uniq_user_history 创建失败。
|
||||||
if err := dedupePlaybackHistories(db); err != nil {
|
if err := dedupePlaybackHistories(db); err != nil {
|
||||||
@@ -32,6 +44,9 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
if err := ensurePerformanceIndexes(db); err != nil {
|
if err := ensurePerformanceIndexes(db); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := ensureMediaPathIndex(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := ensureLibraryRootsCompatibility(db); err != nil {
|
if err := ensureLibraryRootsCompatibility(db); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -44,6 +59,111 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
}
|
}
|
||||||
return ensureSQLiteQueryOptimizer(db)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,16 +296,35 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error {
|
|||||||
if !isPostgres(db) {
|
if !isPostgres(db) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
statements := []string{
|
// 只在类型确实不匹配时才 ALTER。
|
||||||
`ALTER TABLE media ALTER COLUMN container TYPE varchar(128)`,
|
//
|
||||||
`ALTER TABLE media ALTER COLUMN genres TYPE text`,
|
// PostgreSQL 的 ALTER COLUMN ... TYPE 只要类型声明与目标不同就会整表重写并
|
||||||
`ALTER TABLE media ALTER COLUMN series_id TYPE varchar(128)`,
|
// 重建相关索引,执行期间持有 ACCESS EXCLUSIVE 锁。此前这里每次启动都无条件
|
||||||
`ALTER TABLE media ALTER COLUMN duplicate_of TYPE varchar(128)`,
|
// 发这 7 条语句:类型其实早已一致,但几十万行的 media 表仍然被反复重写,
|
||||||
`ALTER TABLE playback_histories ALTER COLUMN media_id TYPE varchar(128)`,
|
// 启动会卡住数分钟,且期间这张表连读都被阻塞。
|
||||||
`ALTER TABLE favorites ALTER COLUMN media_id TYPE varchar(128)`,
|
changes := []struct {
|
||||||
`ALTER TABLE playlist_items ALTER COLUMN media_id TYPE varchar(128)`,
|
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 {
|
if err := db.Exec(stmt).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -193,6 +332,35 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error {
|
|||||||
return nil
|
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 {
|
func ensurePerformanceIndexes(db *gorm.DB) error {
|
||||||
statements := []string{
|
statements := []string{
|
||||||
// 完整多级排序索引:媒体库分页与首页预览的 ORDER BY
|
// 完整多级排序索引:媒体库分页与首页预览的 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 == ""。
|
// Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。
|
||||||
type Media struct {
|
type Media struct {
|
||||||
Base
|
Base
|
||||||
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
|
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"`
|
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
|
||||||
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
|
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
|
||||||
Title string `gorm:"size:255;not null" json:"title"`
|
Title string `gorm:"size:255;not null" json:"title"`
|
||||||
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
|
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
|
||||||
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
|
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
|
||||||
Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"`
|
// 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"`
|
RelativePath string `gorm:"size:1024" json:"relative_path,omitempty"`
|
||||||
SizeBytes int64 `json:"size_bytes"`
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
DurationSec int `json:"duration_sec"`
|
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)
|
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 {
|
func (r *HistoryRepository) upsert(ctx context.Context, h *model.PlaybackHistory, versioned bool) error {
|
||||||
updates := playbackHistoryAssignments(h)
|
updates := playbackHistoryAssignments(h)
|
||||||
if versioned {
|
if versioned {
|
||||||
@@ -49,16 +63,16 @@ func (r *HistoryRepository) upsert(ctx context.Context, h *model.PlaybackHistory
|
|||||||
onConflict.Where = clause.Where{Exprs: []clause.Expression{
|
onConflict.Where = clause.Where{Exprs: []clause.Expression{
|
||||||
clause.Or(
|
clause.Or(
|
||||||
clause.Lt{
|
clause.Lt{
|
||||||
Column: clause.Column{Name: "session_started_at_ms"},
|
Column: historyColumn("session_started_at_ms"),
|
||||||
Value: h.SessionStartedAtMs,
|
Value: h.SessionStartedAtMs,
|
||||||
},
|
},
|
||||||
clause.And(
|
clause.And(
|
||||||
clause.Eq{
|
clause.Eq{
|
||||||
Column: clause.Column{Name: "session_started_at_ms"},
|
Column: historyColumn("session_started_at_ms"),
|
||||||
Value: h.SessionStartedAtMs,
|
Value: h.SessionStartedAtMs,
|
||||||
},
|
},
|
||||||
clause.Lte{
|
clause.Lte{
|
||||||
Column: clause.Column{Name: "sequence"},
|
Column: historyColumn("sequence"),
|
||||||
Value: h.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 := r.db.WithContext(ctx).Model(&model.Media{})
|
||||||
q = applyMediaQueryFilter(q, filter)
|
q = applyMediaQueryFilter(q, filter)
|
||||||
// SQLite 的 LIKE 对 ASCII 不区分大小写;Postgres 的 LIKE 区分大小写,
|
// SQLite 的 LIKE 对 ASCII 不区分大小写;Postgres 的 LIKE 区分大小写,
|
||||||
// 需用 ILIKE 保持两端搜索行为一致。
|
// 需用 ILIKE 保持两端搜索行为一致(见 dialect.go 的 likeOperator)。
|
||||||
likeOp := "LIKE"
|
likeOp := likeOperator(r.db)
|
||||||
if r.db.Dialector != nil && r.db.Dialector.Name() == "postgres" {
|
|
||||||
likeOp = "ILIKE"
|
|
||||||
}
|
|
||||||
terms := mediaSearchTerms(query)
|
terms := mediaSearchTerms(query)
|
||||||
for _, term := range terms {
|
for _, term := range terms {
|
||||||
like := "%" + escapeLike(term) + "%"
|
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
|
return r.db.WithContext(ctx).Clauses(onConflict).Create(row).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListPrewarmCandidates returns media that should be refreshed from source:
|
// segmentPrewarmCandidatesQuery 是片段预取候选查询。
|
||||||
// no ledger, expired miss (fetched_at < missBefore), or expired hit (fetched_at < hitBefore).
|
//
|
||||||
// Recently played items come first so hot titles recover coverage sooner.
|
// 抽成包级常量是为了让方言相关的回归测试能直接断言语句文本:DryRun 模式下
|
||||||
func (r *MediaSegmentRepository) ListPrewarmCandidates(
|
// GORM 不会把 Raw 语句交给 logger,无法从执行路径上捕获生成后的 SQL。
|
||||||
ctx context.Context,
|
//
|
||||||
source string,
|
// 注意 found 必须用占位符绑定布尔值,不能写 f.found = 0 / = 1:
|
||||||
missBefore, hitBefore time.Time,
|
// PostgreSQL 没有 boolean = integer 运算符,那样写会固定报 SQLSTATE 42883
|
||||||
limit int,
|
// (SQLite 宽容,所以只在 PostgreSQL 上暴露)。
|
||||||
) ([]model.Media, error) {
|
const segmentPrewarmCandidatesQuery = `
|
||||||
if r == nil || limit <= 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
rows := make([]model.Media, 0, limit)
|
|
||||||
// 可查询:有 TMDb,且是剧集(有季集)或电影(无季集)。
|
|
||||||
err := r.db.WithContext(ctx).Raw(`
|
|
||||||
SELECT m.*
|
SELECT m.*
|
||||||
FROM media m
|
FROM media m
|
||||||
LEFT JOIN media_segment_fetches f
|
LEFT JOIN media_segment_fetches f
|
||||||
@@ -119,11 +113,29 @@ WHERE m.deleted_at IS NULL
|
|||||||
)
|
)
|
||||||
AND (
|
AND (
|
||||||
f.id IS NULL
|
f.id IS NULL
|
||||||
OR (f.found = 0 AND f.fetched_at < ?)
|
OR (f.found = ? AND f.fetched_at < ?)
|
||||||
OR (f.found = 1 AND f.fetched_at < ?)
|
OR (f.found = ? AND f.fetched_at < ?)
|
||||||
)
|
)
|
||||||
ORDER BY ph.last_played DESC
|
ORDER BY ph.last_played DESC
|
||||||
LIMIT ?
|
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
|
return rows, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,9 +384,17 @@ func (r *ReaderRepository) CountChaptersByBook(ctx context.Context, bookIDs []st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetChapter 取指定章节。
|
// 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) {
|
func (r *ReaderRepository) GetChapter(ctx context.Context, bookID string, index int) (*model.ReaderChapter, error) {
|
||||||
var out model.ReaderChapter
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &out, nil
|
return &out, nil
|
||||||
|
|||||||
@@ -38,16 +38,19 @@ func (l *sqlCaptureLogger) all() string {
|
|||||||
|
|
||||||
// newPostgresDryRunRepos 返回使用 PostgreSQL 方言的 repository 容器:
|
// newPostgresDryRunRepos 返回使用 PostgreSQL 方言的 repository 容器:
|
||||||
// DryRun 只构建 SQL 不执行,DisableAutomaticPing 让它无需真实数据库也能离线运行,
|
// DryRun 只构建 SQL 不执行,DisableAutomaticPing 让它无需真实数据库也能离线运行,
|
||||||
// 于是可以直接断言生成语句在 PostgreSQL 下是否合法。
|
// SkipDefaultTransaction 则挡掉 GORM 默认事务包装——那一步会真的去开事务连接,
|
||||||
|
// 在 DryRun 下同样会拨号(ToSQL 内部也是这么绕开的)。于是可以直接断言生成语句
|
||||||
|
// 在 PostgreSQL 下是否合法。
|
||||||
func newPostgresDryRunRepos(t *testing.T) (*Container, *sqlCaptureLogger) {
|
func newPostgresDryRunRepos(t *testing.T) (*Container, *sqlCaptureLogger) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
rec := &sqlCaptureLogger{}
|
rec := &sqlCaptureLogger{}
|
||||||
db, err := gorm.Open(postgres.New(postgres.Config{
|
db, err := gorm.Open(postgres.New(postgres.Config{
|
||||||
DSN: "postgres://mebox:mebox@127.0.0.1:5432/mebox?sslmode=disable",
|
DSN: "postgres://mebox:mebox@127.0.0.1:5432/mebox?sslmode=disable",
|
||||||
}), &gorm.Config{
|
}), &gorm.Config{
|
||||||
DryRun: true,
|
DryRun: true,
|
||||||
DisableAutomaticPing: true,
|
DisableAutomaticPing: true,
|
||||||
Logger: rec,
|
SkipDefaultTransaction: true,
|
||||||
|
Logger: rec,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open dry-run postgres: %v", err)
|
t.Fatalf("open dry-run postgres: %v", err)
|
||||||
|
|||||||
+64
-18
@@ -1,9 +1,13 @@
|
|||||||
// Package service — database hot backup and restore.
|
// Package service — database hot backup and restore.
|
||||||
//
|
//
|
||||||
// BackupService uses SQLite's "VACUUM INTO" (available since 3.27.0) to
|
// Backup files are stored under {data_dir}/backups/ with a timestamp suffix.
|
||||||
// produce a consistent copy of the database without blocking writers.
|
// 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
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -65,18 +69,34 @@ func (b *BackupService) backupFilePath(filename string) (string, error) {
|
|||||||
return path, nil
|
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) {
|
func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
|
||||||
dir := b.backupDir()
|
dir := b.backupDir()
|
||||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ts := time.Now().Format("20060102_150405")
|
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.
|
var (
|
||||||
if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", dst).Error; err != nil {
|
dst string
|
||||||
return nil, fmt.Errorf("VACUUM INTO failed: %w", err)
|
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)
|
stat, err := os.Stat(dst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -91,6 +111,15 @@ func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
|
|||||||
}, nil
|
}, 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.
|
// List returns every backup file in the backup directory, newest first.
|
||||||
func (b *BackupService) List() ([]BackupInfo, error) {
|
func (b *BackupService) List() ([]BackupInfo, error) {
|
||||||
dir := b.backupDir()
|
dir := b.backupDir()
|
||||||
@@ -103,7 +132,7 @@ func (b *BackupService) List() ([]BackupInfo, error) {
|
|||||||
}
|
}
|
||||||
out := make([]BackupInfo, 0, len(entries))
|
out := make([]BackupInfo, 0, len(entries))
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") {
|
if e.IsDir() || !isBackupFilename(e.Name()) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
info, err := e.Info()
|
info, err := e.Info()
|
||||||
@@ -132,9 +161,10 @@ func (b *BackupService) Delete(filename string) error {
|
|||||||
return os.Remove(path)
|
return os.Remove(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore copies a backup over the live database using VACUUM INTO in
|
// Restore replaces the live database with the given backup.
|
||||||
// reverse. WARNING: this is destructive — the live DB will be replaced.
|
// WARNING: this is destructive. SQLite restores by swapping the database file
|
||||||
// Callers should shut down the server after this call.
|
// (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 {
|
func (b *BackupService) Restore(ctx context.Context, filename string) error {
|
||||||
src, err := b.backupFilePath(filename)
|
src, err := b.backupFilePath(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -143,6 +173,19 @@ func (b *BackupService) Restore(ctx context.Context, filename string) error {
|
|||||||
if _, err := os.Stat(src); err != nil {
|
if _, err := os.Stat(src); err != nil {
|
||||||
return fmt.Errorf("backup not found: %s", filename)
|
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)
|
dbPath, err := filepath.Abs(b.cfg.Database.DBPath)
|
||||||
if err != nil || dbPath == "" {
|
if err != nil || dbPath == "" {
|
||||||
return errors.New("invalid database path")
|
return errors.New("invalid database path")
|
||||||
@@ -171,7 +214,13 @@ func (b *BackupService) Restore(ctx context.Context, filename string) error {
|
|||||||
return nil
|
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 {
|
func isValidBackupFilename(name string) bool {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return false
|
return false
|
||||||
@@ -179,8 +228,5 @@ func isValidBackupFilename(name string) bool {
|
|||||||
if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
|
if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(name, ".db") {
|
return isBackupFilename(name)
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
"time"
|
||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"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) {
|
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)
|
q = q.Where("library_id IN ? OR series_id = ?", e.mergedLibraryIDs(ctx, p.ParentID), p.ParentID)
|
||||||
}
|
}
|
||||||
if p.SearchTerm != "" {
|
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 containsEmbyFilter(p.Filters, "IsFavorite") {
|
||||||
if strings.TrimSpace(p.UserID) == "" {
|
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))
|
q = q.Where("library_id IN ?", e.mergedLibraryIDs(ctx, libraryID))
|
||||||
}
|
}
|
||||||
if p.SearchTerm != "" {
|
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 containsEmbyFilter(p.Filters, "IsFavorite") {
|
||||||
if strings.TrimSpace(p.UserID) == "" {
|
if strings.TrimSpace(p.UserID) == "" {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// movieLibraryHasEpisodicContent 报告电影类型库里是否混入了「剧集结构」内容
|
// movieLibraryHasEpisodicContent 报告电影类型库里是否混入了「剧集结构」内容
|
||||||
@@ -51,7 +52,11 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map
|
|||||||
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
|
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
|
||||||
q = q.Where("library_id IN ?", libIDs)
|
q = q.Where("library_id IN ?", libIDs)
|
||||||
if p.SearchTerm != "" {
|
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 containsEmbyFilter(p.Filters, "IsFavorite") {
|
||||||
if strings.TrimSpace(p.UserID) == "" {
|
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...)
|
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) {
|
func embyLikelyEpisodicPathSQL() (string, []any) {
|
||||||
patterns := []string{
|
patterns := []string{
|
||||||
"%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%",
|
"%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%",
|
||||||
@@ -274,15 +287,11 @@ func embyLikelyEpisodicPathSQL() (string, []any) {
|
|||||||
"%/电视剧/%", "%/剧集/%", "%/连续剧/%", "%/短剧/%", "%/国产剧/%", "%/国剧/%", "%/大陆剧/%", "%/华语剧/%", "%/国产电视剧/%", "%/大陆电视剧/%", "%/华语电视剧/%", "%/欧美剧/%", "%/欧美电视剧/%", "%/美剧/%", "%/英剧/%", "%/日韩剧/%", "%/日韩电视剧/%", "%/日剧/%", "%/韩剧/%", "%/港剧/%", "%/台剧/%", "%/港台剧/%", "%/泰剧/%",
|
"%/电视剧/%", "%/剧集/%", "%/连续剧/%", "%/短剧/%", "%/国产剧/%", "%/国剧/%", "%/大陆剧/%", "%/华语剧/%", "%/国产电视剧/%", "%/大陆电视剧/%", "%/华语电视剧/%", "%/欧美剧/%", "%/欧美电视剧/%", "%/美剧/%", "%/英剧/%", "%/日韩剧/%", "%/日韩电视剧/%", "%/日剧/%", "%/韩剧/%", "%/港剧/%", "%/台剧/%", "%/港台剧/%", "%/泰剧/%",
|
||||||
"%/日番/%", "%/国漫/%", "%/番剧/%", "%/动漫/%", "%/特别篇/%", "%/特別篇/%", "%/番外/%", "%/特典/%",
|
"%/日番/%", "%/国漫/%", "%/番剧/%", "%/动漫/%", "%/特别篇/%", "%/特別篇/%", "%/番外/%", "%/特典/%",
|
||||||
}
|
}
|
||||||
clauses := make([]string, 0, len(patterns)*2)
|
clauses := make([]string, 0, len(patterns))
|
||||||
args := make([]any, 0, len(patterns)*2)
|
args := make([]any, 0, len(patterns))
|
||||||
for _, pattern := range patterns {
|
for _, pattern := range patterns {
|
||||||
clauses = append(clauses, "LOWER(media.path) LIKE ?")
|
clauses = append(clauses, embyEpisodicPathExpr+" LIKE ?")
|
||||||
args = append(args, pattern)
|
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
|
return strings.Join(clauses, " OR "), args
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,12 @@ func LookupLocalAvailability(ctx context.Context, repo *repository.Container, ti
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
like := "%" + query + "%"
|
like := "%" + query + "%"
|
||||||
|
// 方言相关的 ILIKE/LIKE:PostgreSQL 下用 LIKE 会因标题大小写与查询不一致而
|
||||||
|
// 漏判「本地已有该剧」,进而反复提示订阅缺失。
|
||||||
|
likeOp := repository.LikeOperator(repo.DB)
|
||||||
var rows []model.Media
|
var rows []model.Media
|
||||||
if err := repo.DB.WithContext(ctx).
|
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").
|
Order("season_num asc, episode_num asc, episode_fraction asc, created_at desc").
|
||||||
Limit(2000).
|
Limit(2000).
|
||||||
Find(&rows).Error; err != nil {
|
Find(&rows).Error; err != nil {
|
||||||
|
|||||||
+109
-64
@@ -1147,6 +1147,95 @@ export function PlayerPage() {
|
|||||||
mediaRef.current = media
|
mediaRef.current = media
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!mediaId || !ref.current) return
|
if (!mediaId || !ref.current) return
|
||||||
const currentMedia = mediaRef.current
|
const currentMedia = mediaRef.current
|
||||||
@@ -1297,6 +1386,8 @@ export function PlayerPage() {
|
|||||||
}
|
}
|
||||||
video.src = url
|
video.src = url
|
||||||
void video.play().catch(() => undefined)
|
void video.play().catch(() => undefined)
|
||||||
|
// 起播兜底:上游不回 error 时会一直停在 readyState=0,见 armDirectStallWatchdog。
|
||||||
|
armDirectStallWatchdog()
|
||||||
}
|
}
|
||||||
if (hlsUnavailable && needsTranscodeForBrowser(currentMedia)) {
|
if (hlsUnavailable && needsTranscodeForBrowser(currentMedia)) {
|
||||||
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
|
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
|
||||||
@@ -1311,6 +1402,7 @@ export function PlayerPage() {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
activeBurnedSubtitleStream,
|
activeBurnedSubtitleStream,
|
||||||
|
armDirectStallWatchdog,
|
||||||
clearFallbackTimer,
|
clearFallbackTimer,
|
||||||
cloudReloadNonce,
|
cloudReloadNonce,
|
||||||
cloudWaiting,
|
cloudWaiting,
|
||||||
@@ -2174,16 +2266,21 @@ export function PlayerPage() {
|
|||||||
directRetryRef.current = true
|
directRetryRef.current = true
|
||||||
retryingDirectRef.current = true
|
retryingDirectRef.current = true
|
||||||
try {
|
try {
|
||||||
// STRM/115 直连:load() 会清空进度从头播。软恢复即可。
|
// 必须真正重新发起请求。媒体元素加载失败后 networkState 已经是
|
||||||
if (isStrmMedia(mediaRef.current) || pendingDirectSeekRef.current !== null) {
|
// NETWORK_NO_SOURCE(不是 NETWORK_EMPTY),此时只调 play() 不会重跑资源
|
||||||
const pending = pendingDirectSeekRef.current
|
// 选取算法:既不会重新请求,也不会再触发一次 error,于是没人去挂 fallback
|
||||||
void video.play().catch(() => undefined)
|
// 定时器——上游 403/502 一次就把播放器钉死在「direct + 空 src」上,没有
|
||||||
if (pending !== null && pending > 1) {
|
// 错误提示也不会自动播放,只能退出重进或手动切播放方式。
|
||||||
seekStrmDirectTo(pending)
|
//
|
||||||
}
|
// classifyDirectPlayError 只在 readyState < HAVE_CURRENT_DATA 时返回
|
||||||
} else {
|
// 'retry',本来就没有可保住的播放进度,所以 load() 是安全的;需要续播
|
||||||
video.load()
|
// 的位置由 seekStrmDirectTo 在 metadata 就绪后补回。
|
||||||
void video.play().catch(() => undefined)
|
const resumeAt = pendingDirectSeekRef.current
|
||||||
|
pendingDirectSeekRef.current = null
|
||||||
|
video.load()
|
||||||
|
void video.play().catch(() => undefined)
|
||||||
|
if (resumeAt !== null && resumeAt > 1) {
|
||||||
|
seekStrmDirectTo(resumeAt)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
retryingDirectRef.current = false
|
retryingDirectRef.current = false
|
||||||
@@ -2191,60 +2288,8 @@ export function PlayerPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackDirectPlay = () => {
|
scheduleDirectFallback()
|
||||||
if (modeRef.current !== 'direct') return
|
}, [mediaId, mode, scheduleDirectFallback, seekStrmDirectTo])
|
||||||
// 宽限期内又触发的迟到 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,
|
|
||||||
])
|
|
||||||
|
|
||||||
const changeSubtitlePosition = useCallback((nextPosition: SubtitlePosition) => {
|
const changeSubtitlePosition = useCallback((nextPosition: SubtitlePosition) => {
|
||||||
setSubtitlePosition(nextPosition)
|
setSubtitlePosition(nextPosition)
|
||||||
|
|||||||
Reference in New Issue
Block a user