fix library deletion and strm path handling

This commit is contained in:
ShukeBta
2026-06-27 15:58:15 +08:00
parent d18d4239db
commit e22b537de4
6 changed files with 147 additions and 12 deletions
+59 -1
View File
@@ -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) {
+26 -10
View File
@@ -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
}
+29
View File
@@ -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))), "/")
+15
View File
@@ -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)
+16
View File
@@ -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('/')
}
+2 -1
View File
@@ -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 {