support multiple roots per library

This commit is contained in:
ShukeBta
2026-06-26 22:18:26 +08:00
parent bdc3a169f7
commit 5ef831f830
25 changed files with 1382 additions and 171 deletions
+108
View File
@@ -1,6 +1,9 @@
package database
import (
"path/filepath"
"strings"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
@@ -20,6 +23,9 @@ func AutoMigrate(db *gorm.DB) error {
if err := ensurePerformanceIndexes(db); err != nil {
return err
}
if err := ensureLibraryRootsCompatibility(db); err != nil {
return err
}
if isSQLite(db) {
return ensureMediaSearchIndex(db)
}
@@ -52,6 +58,7 @@ func ensurePerformanceIndexes(db *gorm.DB) error {
statements := []string{
`CREATE INDEX IF NOT EXISTS idx_media_library_created_active ON media(library_id, created_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_episode_active ON media(library_id, season_num, episode_num, created_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_root_active ON media(library_id, library_root_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_series_active ON media(series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_favorites_user_media_active ON favorites(user_id, media_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_playback_histories_user_media_active ON playback_histories(user_id, media_id, watched_at DESC) WHERE deleted_at IS NULL`,
@@ -77,6 +84,107 @@ 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.