From 3b1e79789351f6d8ac6037f590e9b3925724c125 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:10:01 +0800 Subject: [PATCH] split database migration helpers --- internal/database/schema_library_roots.go | 111 +++++++++++++ internal/database/schema_media_search.go | 91 ++++++++++ internal/database/schema_migration.go | 192 ---------------------- 3 files changed, 202 insertions(+), 192 deletions(-) create mode 100644 internal/database/schema_library_roots.go create mode 100644 internal/database/schema_media_search.go diff --git a/internal/database/schema_library_roots.go b/internal/database/schema_library_roots.go new file mode 100644 index 0000000..2d45171 --- /dev/null +++ b/internal/database/schema_library_roots.go @@ -0,0 +1,111 @@ +package database + +import ( + "path/filepath" + "strings" + + "gorm.io/gorm" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func ensureLibraryRootsCompatibility(db *gorm.DB) error { + var libraries []model.Library + if err := db.Find(&libraries).Error; err != nil { + return err + } + for _, lib := range libraries { + if strings.TrimSpace(lib.Path) == "" { + continue + } + var count int64 + if err := db.Model(&model.LibraryRoot{}).Where("library_id = ?", lib.ID).Count(&count).Error; err != nil { + return err + } + if count > 0 { + continue + } + root := model.LibraryRoot{ + LibraryID: lib.ID, + Name: firstLibraryRootLabel(lib.Path), + Path: lib.Path, + Enabled: lib.Enabled, + SortOrder: 0, + } + if err := db.Create(&root).Error; err != nil { + return err + } + if err := backfillLibraryRootMedia(db, lib, root); err != nil { + return err + } + } + return nil +} + +func backfillLibraryRootMedia(db *gorm.DB, lib model.Library, root model.LibraryRoot) error { + var rows []model.Media + if err := db.Unscoped(). + Model(&model.Media{}). + Select("id", "path"). + Where("library_id = ? AND (library_root_id = '' OR library_root_id IS NULL)", lib.ID). + Find(&rows).Error; err != nil { + return err + } + rootPath := strings.TrimSpace(root.Path) + for _, row := range rows { + rel, ok := relativePathWithinRoot(row.Path, rootPath) + if !ok { + continue + } + if err := db.Unscoped().Model(&model.Media{}).Where("id = ?", row.ID).Updates(map[string]any{ + "library_root_id": root.ID, + "relative_path": rel, + }).Error; err != nil { + return err + } + } + return nil +} + +func relativePathWithinRoot(pathValue, root string) (string, bool) { + pathValue = strings.TrimSpace(pathValue) + root = strings.TrimSpace(root) + if pathValue == "" || root == "" { + return "", false + } + if strings.HasPrefix(strings.ToLower(root), "cloud://") || strings.HasPrefix(strings.ToLower(pathValue), "cloud://") { + prefix := strings.TrimRight(root, "/") + "/" + if strings.EqualFold(pathValue, root) { + return "", true + } + if strings.HasPrefix(strings.ToLower(pathValue), strings.ToLower(prefix)) { + return strings.TrimPrefix(pathValue, prefix), true + } + return "", false + } + cleanPath := filepath.Clean(pathValue) + cleanRoot := filepath.Clean(root) + rel, err := filepath.Rel(cleanRoot, cleanPath) + if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { + return "", false + } + return rel, true +} + +func firstLibraryRootLabel(pathValue string) string { + pathValue = strings.TrimSpace(pathValue) + if pathValue == "" { + return "" + } + if strings.HasPrefix(strings.ToLower(pathValue), "cloud://") { + parts := strings.Split(strings.Trim(pathValue, "/"), "/") + if len(parts) > 0 { + return parts[len(parts)-1] + } + } + base := filepath.Base(filepath.Clean(pathValue)) + if base == "." || base == string(filepath.Separator) { + return pathValue + } + return base +} diff --git a/internal/database/schema_media_search.go b/internal/database/schema_media_search.go new file mode 100644 index 0000000..f38ba45 --- /dev/null +++ b/internal/database/schema_media_search.go @@ -0,0 +1,91 @@ +package database + +import "gorm.io/gorm" + +// mediaSearchIndexSchemaVersion identifies the physical FTS index layout. +// v2 aligns FTS rowids with media rowids and keeps the index current with +// triggers. +const mediaSearchIndexSchemaVersion = 2 + +func ensureMediaSearchIndex(db *gorm.DB) error { + if err := ensureMediaSearchMetaTable(db); err != nil { + return nil + } + version := currentMediaSearchIndexVersion(db) + if version != mediaSearchIndexSchemaVersion { + resetMediaSearchIndex(db) + } + if !createMediaSearchFTSTable(db) { + return nil + } + if err := createMediaSearchTriggers(db); err != nil { + return err + } + if version != mediaSearchIndexSchemaVersion { + return markMediaSearchIndexVersion(db) + } + return nil +} + +func ensureMediaSearchMetaTable(db *gorm.DB) error { + return db.Exec(`CREATE TABLE IF NOT EXISTS media_search_meta (id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL)`).Error +} + +func currentMediaSearchIndexVersion(db *gorm.DB) int { + var version int + _ = db.Raw(`SELECT version FROM media_search_meta WHERE id = 1`).Scan(&version).Error + return version +} + +func resetMediaSearchIndex(db *gorm.DB) { + for _, stmt := range []string{ + `DROP TRIGGER IF EXISTS media_search_fts_ai`, + `DROP TRIGGER IF EXISTS media_search_fts_au`, + `DROP TRIGGER IF EXISTS media_search_fts_ad`, + `DROP TABLE IF EXISTS media_search_fts`, + } { + _ = db.Exec(stmt).Error + } +} + +func createMediaSearchFTSTable(db *gorm.DB) bool { + if err := db.Exec(`CREATE VIRTUAL TABLE IF NOT EXISTS media_search_fts USING fts5(media_id UNINDEXED, title, original_name, path, genres, tokenize='trigram')`).Error; err == nil { + return true + } + if err := db.Exec(`CREATE VIRTUAL TABLE IF NOT EXISTS media_search_fts USING fts5(media_id UNINDEXED, title, original_name, path, genres, tokenize='unicode61')`).Error; err == nil { + return true + } + // FTS is an acceleration path. Some embedded SQLite builds may omit FTS5; + // keep startup working and let repository queries fall back to LIKE search. + return false +} + +func createMediaSearchTriggers(db *gorm.DB) error { + for _, stmt := range mediaSearchTriggerStatements { + if err := db.Exec(stmt).Error; err != nil { + return err + } + } + return nil +} + +var mediaSearchTriggerStatements = []string{ + `CREATE TRIGGER IF NOT EXISTS media_search_fts_ai AFTER INSERT ON media WHEN new.deleted_at IS NULL BEGIN + DELETE FROM media_search_fts WHERE rowid = new.rowid; + INSERT INTO media_search_fts(rowid, media_id, title, original_name, path, genres) + VALUES (new.rowid, new.id, COALESCE(new.title, ''), COALESCE(new.original_name, ''), COALESCE(new.path, ''), COALESCE(new.genres, '')); + END`, + `CREATE TRIGGER IF NOT EXISTS media_search_fts_au AFTER UPDATE OF title, original_name, path, genres, deleted_at ON media BEGIN + DELETE FROM media_search_fts WHERE rowid = old.rowid; + INSERT INTO media_search_fts(rowid, media_id, title, original_name, path, genres) + SELECT new.rowid, new.id, COALESCE(new.title, ''), COALESCE(new.original_name, ''), COALESCE(new.path, ''), COALESCE(new.genres, '') + WHERE new.deleted_at IS NULL; + END`, + `CREATE TRIGGER IF NOT EXISTS media_search_fts_ad AFTER DELETE ON media BEGIN + DELETE FROM media_search_fts WHERE rowid = old.rowid; + END`, +} + +func markMediaSearchIndexVersion(db *gorm.DB) error { + return db.Exec(`INSERT INTO media_search_meta(id, version) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET version = excluded.version`, mediaSearchIndexSchemaVersion).Error +} diff --git a/internal/database/schema_migration.go b/internal/database/schema_migration.go index 08236ca..c9f5fe5 100644 --- a/internal/database/schema_migration.go +++ b/internal/database/schema_migration.go @@ -1,9 +1,6 @@ package database import ( - "path/filepath" - "strings" - "gorm.io/gorm" "github.com/ShukeBta/MediaStationGo/internal/model" @@ -84,195 +81,6 @@ func ensurePerformanceIndexes(db *gorm.DB) error { return nil } -func ensureLibraryRootsCompatibility(db *gorm.DB) error { - var libraries []model.Library - if err := db.Find(&libraries).Error; err != nil { - return err - } - for _, lib := range libraries { - if strings.TrimSpace(lib.Path) == "" { - continue - } - var count int64 - if err := db.Model(&model.LibraryRoot{}).Where("library_id = ?", lib.ID).Count(&count).Error; err != nil { - return err - } - if count > 0 { - continue - } - root := model.LibraryRoot{ - LibraryID: lib.ID, - Name: firstLibraryRootLabel(lib.Path), - Path: lib.Path, - Enabled: lib.Enabled, - SortOrder: 0, - } - if err := db.Create(&root).Error; err != nil { - return err - } - if err := backfillLibraryRootMedia(db, lib, root); err != nil { - return err - } - } - return nil -} - -func backfillLibraryRootMedia(db *gorm.DB, lib model.Library, root model.LibraryRoot) error { - var rows []model.Media - if err := db.Unscoped(). - Model(&model.Media{}). - Select("id", "path"). - Where("library_id = ? AND (library_root_id = '' OR library_root_id IS NULL)", lib.ID). - Find(&rows).Error; err != nil { - return err - } - rootPath := strings.TrimSpace(root.Path) - for _, row := range rows { - rel, ok := relativePathWithinRoot(row.Path, rootPath) - if !ok { - continue - } - if err := db.Unscoped().Model(&model.Media{}).Where("id = ?", row.ID).Updates(map[string]any{ - "library_root_id": root.ID, - "relative_path": rel, - }).Error; err != nil { - return err - } - } - return nil -} - -func relativePathWithinRoot(pathValue, root string) (string, bool) { - pathValue = strings.TrimSpace(pathValue) - root = strings.TrimSpace(root) - if pathValue == "" || root == "" { - return "", false - } - if strings.HasPrefix(strings.ToLower(root), "cloud://") || strings.HasPrefix(strings.ToLower(pathValue), "cloud://") { - prefix := strings.TrimRight(root, "/") + "/" - if strings.EqualFold(pathValue, root) { - return "", true - } - if strings.HasPrefix(strings.ToLower(pathValue), strings.ToLower(prefix)) { - return strings.TrimPrefix(pathValue, prefix), true - } - return "", false - } - cleanPath := filepath.Clean(pathValue) - cleanRoot := filepath.Clean(root) - rel, err := filepath.Rel(cleanRoot, cleanPath) - if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { - return "", false - } - return rel, true -} - -func firstLibraryRootLabel(pathValue string) string { - pathValue = strings.TrimSpace(pathValue) - if pathValue == "" { - return "" - } - if strings.HasPrefix(strings.ToLower(pathValue), "cloud://") { - parts := strings.Split(strings.Trim(pathValue, "/"), "/") - if len(parts) > 0 { - return parts[len(parts)-1] - } - } - base := filepath.Base(filepath.Clean(pathValue)) - if base == "." || base == string(filepath.Separator) { - return pathValue - } - return base -} - -// mediaSearchIndexSchemaVersion identifies the physical FTS index layout. -// v2 aligns FTS rowids with media rowids and keeps the index current with -// triggers. -const mediaSearchIndexSchemaVersion = 2 - -func ensureMediaSearchIndex(db *gorm.DB) error { - if err := ensureMediaSearchMetaTable(db); err != nil { - return nil - } - version := currentMediaSearchIndexVersion(db) - if version != mediaSearchIndexSchemaVersion { - resetMediaSearchIndex(db) - } - if !createMediaSearchFTSTable(db) { - return nil - } - if err := createMediaSearchTriggers(db); err != nil { - return err - } - if version != mediaSearchIndexSchemaVersion { - return markMediaSearchIndexVersion(db) - } - return nil -} - -func ensureMediaSearchMetaTable(db *gorm.DB) error { - return db.Exec(`CREATE TABLE IF NOT EXISTS media_search_meta (id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL)`).Error -} - -func currentMediaSearchIndexVersion(db *gorm.DB) int { - var version int - _ = db.Raw(`SELECT version FROM media_search_meta WHERE id = 1`).Scan(&version).Error - return version -} - -func resetMediaSearchIndex(db *gorm.DB) { - for _, stmt := range []string{ - `DROP TRIGGER IF EXISTS media_search_fts_ai`, - `DROP TRIGGER IF EXISTS media_search_fts_au`, - `DROP TRIGGER IF EXISTS media_search_fts_ad`, - `DROP TABLE IF EXISTS media_search_fts`, - } { - _ = db.Exec(stmt).Error - } -} - -func createMediaSearchFTSTable(db *gorm.DB) bool { - if err := db.Exec(`CREATE VIRTUAL TABLE IF NOT EXISTS media_search_fts USING fts5(media_id UNINDEXED, title, original_name, path, genres, tokenize='trigram')`).Error; err == nil { - return true - } - if err := db.Exec(`CREATE VIRTUAL TABLE IF NOT EXISTS media_search_fts USING fts5(media_id UNINDEXED, title, original_name, path, genres, tokenize='unicode61')`).Error; err == nil { - return true - } - // FTS is an acceleration path. Some embedded SQLite builds may omit FTS5; - // keep startup working and let repository queries fall back to LIKE search. - return false -} - -func createMediaSearchTriggers(db *gorm.DB) error { - for _, stmt := range mediaSearchTriggerStatements { - if err := db.Exec(stmt).Error; err != nil { - return err - } - } - return nil -} - -var mediaSearchTriggerStatements = []string{ - `CREATE TRIGGER IF NOT EXISTS media_search_fts_ai AFTER INSERT ON media WHEN new.deleted_at IS NULL BEGIN - DELETE FROM media_search_fts WHERE rowid = new.rowid; - INSERT INTO media_search_fts(rowid, media_id, title, original_name, path, genres) - VALUES (new.rowid, new.id, COALESCE(new.title, ''), COALESCE(new.original_name, ''), COALESCE(new.path, ''), COALESCE(new.genres, '')); - END`, - `CREATE TRIGGER IF NOT EXISTS media_search_fts_au AFTER UPDATE OF title, original_name, path, genres, deleted_at ON media BEGIN - DELETE FROM media_search_fts WHERE rowid = old.rowid; - INSERT INTO media_search_fts(rowid, media_id, title, original_name, path, genres) - SELECT new.rowid, new.id, COALESCE(new.title, ''), COALESCE(new.original_name, ''), COALESCE(new.path, ''), COALESCE(new.genres, '') - WHERE new.deleted_at IS NULL; - END`, - `CREATE TRIGGER IF NOT EXISTS media_search_fts_ad AFTER DELETE ON media BEGIN - DELETE FROM media_search_fts WHERE rowid = old.rowid; - END`, -} - -func markMediaSearchIndexVersion(db *gorm.DB) error { - return db.Exec(`INSERT INTO media_search_meta(id, version) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET version = excluded.version`, mediaSearchIndexSchemaVersion).Error -} - func enforceTelegramBindingOneToOne(db *gorm.DB) error { if !db.Migrator().HasTable(&model.TelegramBinding{}) { return nil