mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-13 00:26:37 +08:00
split database migration helpers
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
@@ -84,195 +81,6 @@ func ensurePerformanceIndexes(db *gorm.DB) error {
|
|||||||
return nil
|
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 {
|
func enforceTelegramBindingOneToOne(db *gorm.DB) error {
|
||||||
if !db.Migrator().HasTable(&model.TelegramBinding{}) {
|
if !db.Migrator().HasTable(&model.TelegramBinding{}) {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Reference in New Issue
Block a user