package main import ( "fmt" "os" "path/filepath" "gorm.io/gorm" ) // migrateDatabase applies schema changes and repairs legacy indexes. It is // intentionally separate from startup so migration behavior can be tested on // a disposable SQLite database before touching production data. func migrateDatabase(db *gorm.DB) error { if err := db.AutoMigrate(&Task{}, &MediaAsset{}, &Setting{}, &User{}, &UserPermission{}, &CreditAccount{}, &CreditTransaction{}, &CreditReservation{}, &RedeemBatch{}, &RedeemCode{}, &RedeemRecord{}, &WebDAVConfig{}, &Session{}, &AuditLog{}); err != nil { return err } if err := db.Exec("DROP INDEX IF EXISTS idx_tasks_source_rid_quality").Error; err != nil { return err } if err := db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_user_source_rid_quality ON tasks(user_id, source, r_id, quality)").Error; err != nil { return err } if err := db.Exec("DROP INDEX IF EXISTS idx_credit_reservations_task_id").Error; err != nil { return err } return db.Exec("CREATE INDEX IF NOT EXISTS idx_credit_reservations_task_id ON credit_reservations(task_id)").Error } // backfillMediaAssets turns legacy completed tasks into shared media assets. // Existing physical files are reused; no media is moved or deleted. func backfillMediaAssets(db *gorm.DB, downloadDir string) error { var tasks []Task if err := db.Where("(asset_id = 0 OR asset_id IS NULL) AND status = ?", "done").Find(&tasks).Error; err != nil { return err } for i := range tasks { t := &tasks[i] key := assetKey(t.Source, t.RID, t.Quality) var asset MediaAsset result := db.Where("asset_key = ?", key).Limit(1).Find(&asset) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { asset = MediaAsset{ AssetKey: key, Source: t.Source, RID: t.RID, Quality: t.Quality, LocalPath: t.LocalPath, Format: t.Format, ActualQuality: t.ActualQuality, Size: t.DownloadedBytes, Status: "done", } if asset.LocalPath != "" { if fi, statErr := os.Stat(asset.LocalPath); statErr == nil && fi.Size() > 0 { asset.Size = fi.Size() } } else if downloadDir != "" { candidate := filepath.Join(downloadDir, safe(t.Artist)+" - "+safe(t.Title)+"."+t.Format) if fi, statErr := os.Stat(candidate); statErr == nil && fi.Size() > 0 { asset.LocalPath, asset.Size = candidate, fi.Size() } } if err := db.Create(&asset).Error; err != nil { return fmt.Errorf("create media asset for task %d: %w", t.ID, err) } } if t.AssetID != asset.ID { if err := db.Model(t).Update("asset_id", asset.ID).Error; err != nil { return fmt.Errorf("link media asset for task %d: %w", t.ID, err) } } } return nil }