diff --git a/README.md b/README.md index 4761845..2eacb6b 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,8 @@ docker compose logs -f mediastation-go 如果你确认以后不再使用 SQLite,也不希望应用再把旧 SQLite 当迁移源,可以这样做: +> 只有在网页确认用户、媒体库、设置、媒体条目都已经出现在 PostgreSQL 后,才做下面这一步。 + ```yaml environment: MEDIASTATION_DATABASE_TYPE: postgres diff --git a/README_EN.md b/README_EN.md index b327b09..720d851 100644 --- a/README_EN.md +++ b/README_EN.md @@ -160,6 +160,8 @@ After you see `sqlite data migrated to postgres`, or after the web UI shows your To make the deployment PostgreSQL-only after migration, keep PostgreSQL selected and point the old SQLite migration path at a non-existent file: +> Only do this after the web UI confirms that users, libraries, settings, and media rows are already present in PostgreSQL. + ```yaml environment: MEDIASTATION_DATABASE_TYPE: postgres diff --git a/internal/database/database.go b/internal/database/database.go index 3b42c7d..09548c9 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -115,16 +115,13 @@ func MigrateSQLiteToCurrentIfNeeded(cfg *config.Config, target *gorm.DB, log *za if cfg == nil || target == nil || target.Dialector == nil || target.Dialector.Name() != "postgres" { return nil } - sqlitePath := strings.TrimSpace(cfg.Database.DBPath) + sqlitePath, err := sqliteMigrationSourcePath(cfg, log) + if err != nil { + return err + } if sqlitePath == "" { return nil } - if _, err := os.Stat(sqlitePath); err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - return fmt.Errorf("stat sqlite migration source: %w", err) - } if complete, err := sqliteMigrationMarkedComplete(target); err != nil { return err } else if complete { @@ -148,6 +145,9 @@ func MigrateSQLiteToCurrentIfNeeded(cfg *config.Config, target *gorm.DB, log *za } started := time.Now() + if err := resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src, target, log); err != nil { + return err + } copied, err := copyModelTables(src, target, 500) if err != nil { return err @@ -164,6 +164,149 @@ func MigrateSQLiteToCurrentIfNeeded(cfg *config.Config, target *gorm.DB, log *za return nil } +func sqliteMigrationSourcePath(cfg *config.Config, log *zap.Logger) (string, error) { + configured := strings.TrimSpace(cfg.Database.DBPath) + if configured != "" { + exists, err := regularFileExists(configured) + if err != nil { + return "", fmt.Errorf("stat sqlite migration source: %w", err) + } + if exists { + return configured, nil + } + } + + fallback := filepath.Join(strings.TrimSpace(cfg.App.DataDir), "mediastation.db") + if fallback == "" || sameCleanPath(configured, fallback) { + return "", nil + } + exists, err := regularFileExists(fallback) + if err != nil { + return "", fmt.Errorf("stat default sqlite migration source: %w", err) + } + if !exists { + return "", nil + } + if log != nil && configured != "" { + log.Warn("configured sqlite migration source not found; using data-dir default", + zap.String("configured", configured), + zap.String("fallback", fallback)) + } + return fallback, nil +} + +func regularFileExists(path string) (bool, error) { + if strings.TrimSpace(path) == "" { + return false, nil + } + info, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, err + } + return !info.IsDir(), nil +} + +func sameCleanPath(a, b string) bool { + if a == "" || b == "" { + return false + } + return filepath.Clean(a) == filepath.Clean(b) +} + +func resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src, target *gorm.DB, log *zap.Logger) error { + hasRows, err := sqliteSourceHasMigratableRows(src) + if err != nil { + return err + } + if !hasRows { + return nil + } + bootstrapOnly, err := targetLooksLikeBootstrapOnly(target) + if err != nil || !bootstrapOnly { + return err + } + for i := len(model.AllModels()) - 1; i >= 0; i-- { + m := model.AllModels()[i] + if !target.Migrator().HasTable(m) { + continue + } + if err := target.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(m).Error; err != nil { + return fmt.Errorf("clear bootstrap target table %T: %w", m, err) + } + } + if log != nil { + log.Warn("cleared bootstrap postgres rows before sqlite migration") + } + return nil +} + +func sqliteSourceHasMigratableRows(src *gorm.DB) (bool, error) { + for _, table := range []string{"users", "libraries", "media", "settings"} { + exists, err := sqliteTableExists(src, table) + if err != nil { + return false, err + } + if !exists { + continue + } + var count int64 + if err := src.Raw("SELECT COUNT(1) FROM " + quoteIdent(table)).Scan(&count).Error; err != nil { + return false, fmt.Errorf("count sqlite table %s: %w", table, err) + } + if count > 0 { + return true, nil + } + } + return false, nil +} + +func targetLooksLikeBootstrapOnly(target *gorm.DB) (bool, error) { + for _, m := range []any{ + &model.Library{}, + &model.Series{}, + &model.Media{}, + &model.PlaybackHistory{}, + &model.Favorite{}, + &model.Playlist{}, + &model.PlaylistItem{}, + &model.DownloadTask{}, + &model.Subscription{}, + } { + if !target.Migrator().HasTable(m) { + continue + } + var count int64 + if err := target.Unscoped().Model(m).Count(&count).Error; err != nil { + return false, err + } + if count > 0 { + return false, nil + } + } + + var userCount int64 + if !target.Migrator().HasTable(&model.User{}) { + return true, nil + } + if err := target.Model(&model.User{}).Count(&userCount).Error; err != nil { + return false, err + } + if userCount == 0 { + return true, nil + } + if userCount != 1 { + return false, nil + } + var user model.User + if err := target.Unscoped().Where("username = ?", "admin").First(&user).Error; err != nil { + return false, nil + } + return user.Role == "admin", nil +} + func sqliteMigrationMarkedComplete(db *gorm.DB) (bool, error) { var value string err := db.Raw("SELECT value FROM "+quoteIdent("settings")+" WHERE "+quoteIdent("key")+" = ?", sqliteMigrationCompleteSettingKey).Scan(&value).Error @@ -513,6 +656,7 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error { } statements := []string{ `ALTER TABLE media ALTER COLUMN container TYPE varchar(128)`, + `ALTER TABLE media ALTER COLUMN genres TYPE text`, } for _, stmt := range statements { if err := db.Exec(stmt).Error; err != nil { diff --git a/internal/database/database_test.go b/internal/database/database_test.go index bde9636..6e9fe87 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -1,12 +1,14 @@ package database import ( + "path/filepath" "testing" "time" "github.com/glebarez/sqlite" "gorm.io/gorm" + "github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/model" ) @@ -149,6 +151,7 @@ func TestCopyModelTablesResumesPartialSQLiteMigration(t *testing.T) { VideoCodec: "hevc", AudioCodec: "eac3", DurationSec: 120, + Genres: "家庭,动画,冒险,喜剧,奇幻,Peter Del Vecho,Jeff Draheim,詹妮弗·李,克里斯·巴克,伊迪娜·门泽尔,克里斯汀·贝尔,乔什·盖德,乔纳森·格罗夫,埃文·蕾切尔·伍德,斯特林·K·布朗", SizeBytes: 1024, Width: 3840, Height: 2160, @@ -176,6 +179,9 @@ func TestCopyModelTablesResumesPartialSQLiteMigration(t *testing.T) { if got.Container != media.Container { t.Fatalf("container = %q, want %q", got.Container, media.Container) } + if got.Genres != media.Genres { + t.Fatalf("genres = %q, want %q", got.Genres, media.Genres) + } copied, err = copyModelTables(src, dst, 2) if err != nil { @@ -212,3 +218,87 @@ func TestSQLiteMigrationCompleteMarker(t *testing.T) { t.Fatal("database should be marked migrated") } } + +func TestSQLiteMigrationFallsBackToDataDirDefaultPath(t *testing.T) { + dir := t.TempDir() + sqlitePath := filepath.Join(dir, "mediastation.db") + src, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := src.AutoMigrate(&model.User{}, &model.Library{}); err != nil { + t.Fatal(err) + } + user := model.User{Username: "real-admin", PasswordHash: "hash", Role: "admin", IsActive: true} + if err := src.Create(&user).Error; err != nil { + t.Fatal(err) + } + lib := model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true} + if err := src.Create(&lib).Error; err != nil { + t.Fatal(err) + } + sqlDB, _ := src.DB() + _ = sqlDB.Close() + + dst, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := dst.AutoMigrate(&model.User{}, &model.Library{}, &model.Setting{}); err != nil { + t.Fatal(err) + } + if err := dst.Create(&model.User{Username: "admin", PasswordHash: "bootstrap", Role: "admin", IsActive: true}).Error; err != nil { + t.Fatal(err) + } + + cfg := &config.Config{} + cfg.App.DataDir = dir + cfg.Database.DBPath = filepath.Join(dir, "disabled-sqlite-migration.db") + sourcePath, err := sqliteMigrationSourcePath(cfg, nil) + if err != nil { + t.Fatal(err) + } + if sourcePath != sqlitePath { + t.Fatalf("source path = %q, want fallback %q", sourcePath, sqlitePath) + } + + src2, err := gorm.Open(sqlite.Open(sourcePath), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB2, _ := src2.DB() + defer func() { + if sqlDB2 != nil { + _ = sqlDB2.Close() + } + }() + if err := resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src2, dst, nil); err != nil { + t.Fatal(err) + } + copied, err := copyModelTables(src2, dst, 2) + if err != nil { + t.Fatal(err) + } + if copied != 2 { + t.Fatalf("copied rows = %d, want 2", copied) + } + + var userCount int64 + if err := dst.Model(&model.User{}).Count(&userCount).Error; err != nil { + t.Fatal(err) + } + if userCount != 1 { + t.Fatalf("user count = %d, want migrated source only", userCount) + } + var got model.User + if err := dst.First(&got, "username = ?", "real-admin").Error; err != nil { + t.Fatal(err) + } + var libCount int64 + if err := dst.Model(&model.Library{}).Where("path = ?", "/media/movies").Count(&libCount).Error; err != nil { + t.Fatal(err) + } + if libCount != 1 { + t.Fatalf("library count = %d, want 1", libCount) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 45f9b86..c7f0b9f 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -95,7 +95,7 @@ type Media struct { TheTVDBID string `gorm:"column:thetvdb_id;size:64" json:"thetvdb_id,omitempty"` Languages string `gorm:"size:64" json:"languages,omitempty"` // 逗号分隔的 ISO 639-1 代码,如 "zh,en" Countries string `gorm:"size:128" json:"countries,omitempty"` // 逗号分隔的 ISO 3166-1,如 "CN,US" - Genres string `gorm:"size:255" json:"genres,omitempty"` // 逗号分隔的类型名,如 "Action,Animation" + Genres string `gorm:"type:text" json:"genres,omitempty"` // 逗号分隔的类型名,如 "Action,Animation" NSFW bool `gorm:"default:false" json:"nsfw"` // STRMURL is the indirection target for .strm files: when present the