From e22b537de4ec9a0b60b921f1df04b48e4d17cf76 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:58:15 +0800 Subject: [PATCH] fix library deletion and strm path handling --- internal/service/media_cloud_test.go | 60 +++++++++++++++++++++++++++- internal/service/media_library.go | 36 ++++++++++++----- internal/service/strm_output_dir.go | 29 ++++++++++++++ internal/service/strm_svc_test.go | 15 +++++++ web/src/pages/strmPageModel.ts | 16 ++++++++ web/src/utils/groupSeries.ts | 3 +- 6 files changed, 147 insertions(+), 12 deletions(-) diff --git a/internal/service/media_cloud_test.go b/internal/service/media_cloud_test.go index 7314207..0f89d68 100644 --- a/internal/service/media_cloud_test.go +++ b/internal/service/media_cloud_test.go @@ -1,6 +1,7 @@ package service import ( + "path/filepath" "testing" "go.uber.org/zap" @@ -10,13 +11,63 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/repository" ) +func TestDeleteLibraryHardDeletesLibraryRoots(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}) + repos := repository.New(db) + rootA := filepath.Join(t.TempDir(), "movies-a") + rootB := filepath.Join(t.TempDir(), "movies-b") + lib := &model.Library{Name: "电影", Path: rootA, Type: "movie", Enabled: true} + if err := repos.Library.CreateWithRoots(t.Context(), lib, []model.LibraryRoot{ + {Path: rootA, Enabled: true, SortOrder: 0}, + {Path: rootB, Enabled: true, SortOrder: 1}, + }); err != nil { + t.Fatal(err) + } + if err := repos.Media.Upsert(t.Context(), &model.Media{ + LibraryID: lib.ID, + LibraryRootID: lib.Roots[0].ID, + Title: "测试电影", + Path: filepath.Join(rootA, "movie.mkv"), + }); err != nil { + t.Fatal(err) + } + + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + if err := svc.DeleteLibrary(t.Context(), lib.ID); err != nil { + t.Fatal(err) + } + + var rootCount int64 + if err := db.Unscoped().Model(&model.LibraryRoot{}).Where("library_id = ?", lib.ID).Count(&rootCount).Error; err != nil { + t.Fatal(err) + } + if rootCount != 0 { + t.Fatalf("library roots should be hard deleted, count=%d", rootCount) + } + var visibleLibraryCount int64 + if err := db.Model(&model.Library{}).Where("id = ?", lib.ID).Count(&visibleLibraryCount).Error; err != nil { + t.Fatal(err) + } + if visibleLibraryCount != 0 { + t.Fatalf("deleted library should not remain visible, count=%d", visibleLibraryCount) + } +} + func TestDeleteCloudLibraryPurgesMountWithoutRecycleBin(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) + db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}) repos := repository.New(db) lib := model.Library{Name: "OpenList · 剑来", Path: "cloud://openlist/Anime/JianLai", Type: "anime", Enabled: true} if err := repos.Library.Create(t.Context(), &lib); err != nil { t.Fatal(err) } + if err := repos.DB.Create(&model.LibraryRoot{ + LibraryID: lib.ID, + Name: "JianLai", + Path: lib.Path, + Enabled: true, + }).Error; err != nil { + t.Fatal(err) + } if err := repos.Media.Upsert(t.Context(), &model.Media{ LibraryID: lib.ID, Title: "剑来", @@ -52,6 +103,13 @@ func TestDeleteCloudLibraryPurgesMountWithoutRecycleBin(t *testing.T) { if libCount != 0 { t.Fatalf("cloud mount library should be purged, count=%d", libCount) } + var rootCount int64 + if err := db.Unscoped().Model(&model.LibraryRoot{}).Where("library_id = ?", lib.ID).Count(&rootCount).Error; err != nil { + t.Fatal(err) + } + if rootCount != 0 { + t.Fatalf("cloud mount roots should be purged, count=%d", rootCount) + } } func TestMediaUpsertBackfillsExternalIDsForPendingCloudRows(t *testing.T) { diff --git a/internal/service/media_library.go b/internal/service/media_library.go index b37cc22..b2f2f6b 100644 --- a/internal/service/media_library.go +++ b/internal/service/media_library.go @@ -4,6 +4,7 @@ import ( "context" "github.com/ShukeBta/MediaStationGo/internal/model" + "gorm.io/gorm" ) // ListLibraries returns every library configured on the server. @@ -20,24 +21,39 @@ func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error { } if lib != nil { if _, ok := ParseCloudLibraryMount(lib.Path); ok { - if err := s.repo.Media.PurgeByLibrary(ctx, id); err != nil { - return err - } - _ = s.repo.DB.WithContext(ctx).Where("library_id = ?", id).Delete(&model.LibraryRoot{}).Error - err := s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.Library{}).Error + err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Unscoped().Where("library_id = ?", id).Delete(&model.Media{}).Error; err != nil { + return err + } + if err := hardDeleteLibraryRoots(ctx, tx, id); err != nil { + return err + } + return tx.Unscoped().Where("id = ?", id).Delete(&model.Library{}).Error + }) if err == nil { s.invalidateMediaCache(ctx) } return err } } - _ = s.repo.DB.WithContext(ctx).Where("library_id = ?", id).Delete(&model.LibraryRoot{}).Error - if err := s.repo.Media.DeleteByLibrary(ctx, id); err != nil { - return err - } - err = s.repo.Library.Delete(ctx, id) + err = s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("library_id = ?", id).Delete(&model.Media{}).Error; err != nil { + return err + } + if err := hardDeleteLibraryRoots(ctx, tx, id); err != nil { + return err + } + return tx.Delete(&model.Library{}, "id = ?", id).Error + }) if err == nil { s.invalidateMediaCache(ctx) } return err } + +func hardDeleteLibraryRoots(ctx context.Context, tx *gorm.DB, libraryID string) error { + if tx == nil || !tx.Migrator().HasTable(&model.LibraryRoot{}) { + return nil + } + return tx.WithContext(ctx).Unscoped().Where("library_id = ?", libraryID).Delete(&model.LibraryRoot{}).Error +} diff --git a/internal/service/strm_output_dir.go b/internal/service/strm_output_dir.go index 16d7982..6885173 100644 --- a/internal/service/strm_output_dir.go +++ b/internal/service/strm_output_dir.go @@ -29,6 +29,7 @@ func strmLibrarySpecificOutputDir(base string, lib *model.Library) string { if outputDir == "" || outputDir == "." || lib == nil { return outputDir } + outputDir = stripSTRMLibraryCategoryAfterRoot(outputDir) subdir := strmLibraryOutputSubdir(*lib) if subdir == "" || strmPathHasSuffix(outputDir, subdir) || pathAlreadyEndsWith(outputDir, filepath.Base(subdir)) { return outputDir @@ -36,6 +37,34 @@ func strmLibrarySpecificOutputDir(base string, lib *model.Library) string { return filepath.Join(outputDir, subdir) } +func stripSTRMLibraryCategoryAfterRoot(pathValue string) string { + normalized := filepath.ToSlash(filepath.Clean(strings.TrimSpace(pathValue))) + parts := strings.Split(normalized, "/") + strmIndex := -1 + for i, part := range parts { + if strings.EqualFold(strings.TrimSpace(part), "strm") { + strmIndex = i + } + } + if strmIndex < 0 || strmIndex >= len(parts)-1 { + return pathValue + } + tail := make([]string, 0, len(parts)-strmIndex-1) + for _, part := range parts[strmIndex+1:] { + if strings.TrimSpace(part) != "" { + tail = append(tail, part) + } + } + if len(strmCategoryPartsFromPath(tail)) == 0 { + return pathValue + } + root := strings.Join(parts[:strmIndex+1], "/") + if root == "" { + return pathValue + } + return filepath.FromSlash(root) +} + func strmPathHasSuffix(pathValue, suffix string) bool { pathValue = strings.Trim(filepath.ToSlash(filepath.Clean(strings.TrimSpace(pathValue))), "/") suffix = strings.Trim(filepath.ToSlash(filepath.Clean(strings.TrimSpace(suffix))), "/") diff --git a/internal/service/strm_svc_test.go b/internal/service/strm_svc_test.go index 6f03595..68c816d 100644 --- a/internal/service/strm_svc_test.go +++ b/internal/service/strm_svc_test.go @@ -232,6 +232,21 @@ func TestSTRMLibraryOutputSubdirUsesLibraryCategoryPath(t *testing.T) { } } +func TestSTRMLibrarySpecificOutputDirStripsPreviousLibraryCategory(t *testing.T) { + base := filepath.Join(t.TempDir(), "strm", "电影", "演唱会") + lib := model.Library{ + Name: "国产剧", + Path: BuildCloudLibraryPath("openlist", "/电视剧/国产剧", "/电视剧/国产剧"), + Type: "tv", + } + + got := strmLibrarySpecificOutputDir(base, &lib) + want := filepath.Join(filepath.Dir(filepath.Dir(base)), "电视剧", "国产剧") + if got != want { + t.Fatalf("strmLibrarySpecificOutputDir() = %q, want %q", got, want) + } +} + func TestGenerateSTRMForLibraryUsesCategoryDefaultOutputDir(t *testing.T) { db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) repos := repository.New(db) diff --git a/web/src/pages/strmPageModel.ts b/web/src/pages/strmPageModel.ts index 445b81f..f5e80b7 100644 --- a/web/src/pages/strmPageModel.ts +++ b/web/src/pages/strmPageModel.ts @@ -58,6 +58,8 @@ export function inferSTRMOutputRoot(saved: string, libraries: Library[]) { } if (slashSaved === toSlash(subdir).toLowerCase()) return '' } + const inferredBySTRMRoot = stripLibraryCategoryAfterSTRMRoot(normalized) + if (inferredBySTRMRoot) return inferredBySTRMRoot return normalized } @@ -133,6 +135,20 @@ function splitPath(raw: string) { return raw.split('/').map((part) => part.trim()).filter((part) => part && part !== '.') } +function stripLibraryCategoryAfterSTRMRoot(raw: string) { + const normalized = toSlash(raw) + const parts = normalized.split('/') + let strmIndex = -1 + for (let index = 0; index < parts.length; index += 1) { + if (parts[index].trim().toLowerCase() === 'strm') strmIndex = index + } + if (strmIndex < 0 || strmIndex >= parts.length - 1) return '' + const tail = parts.slice(strmIndex + 1).filter((part) => part.trim()) + const categoryParts = categoryPartsFromPath(tail) + if (!categoryParts?.length) return '' + return trimPath(parts.slice(0, strmIndex + 1).join('/')) +} + function joinPath(root: string, ...parts: string[]) { return [trimPath(root), ...parts.map(trimPath)].filter(Boolean).join('/') } diff --git a/web/src/utils/groupSeries.ts b/web/src/utils/groupSeries.ts index e887910..6bbc289 100644 --- a/web/src/utils/groupSeries.ts +++ b/web/src/utils/groupSeries.ts @@ -99,8 +99,9 @@ export function isSeriesCard(card: SeriesCard): boolean { } export function seriesTitle(media: Media): string { + const title = media.title?.trim() const fromPath = seriesTitleFromPath(media.path) - return fromPath || media.title || media.original_name || '未命名节目' + return (title && !unsafeEpisodeTitle(title) ? title : '') || fromPath || media.original_name || title || '未命名节目' } function normalizeTitle(value?: string): string {