mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 04:56:37 +08:00
fix library deletion and strm path handling
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -10,13 +11,63 @@ import (
|
|||||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
"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) {
|
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)
|
repos := repository.New(db)
|
||||||
lib := model.Library{Name: "OpenList · 剑来", Path: "cloud://openlist/Anime/JianLai", Type: "anime", Enabled: true}
|
lib := model.Library{Name: "OpenList · 剑来", Path: "cloud://openlist/Anime/JianLai", Type: "anime", Enabled: true}
|
||||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||||
t.Fatal(err)
|
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{
|
if err := repos.Media.Upsert(t.Context(), &model.Media{
|
||||||
LibraryID: lib.ID,
|
LibraryID: lib.ID,
|
||||||
Title: "剑来",
|
Title: "剑来",
|
||||||
@@ -52,6 +103,13 @@ func TestDeleteCloudLibraryPurgesMountWithoutRecycleBin(t *testing.T) {
|
|||||||
if libCount != 0 {
|
if libCount != 0 {
|
||||||
t.Fatalf("cloud mount library should be purged, count=%d", libCount)
|
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) {
|
func TestMediaUpsertBackfillsExternalIDsForPendingCloudRows(t *testing.T) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListLibraries returns every library configured on the server.
|
// 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 lib != nil {
|
||||||
if _, ok := ParseCloudLibraryMount(lib.Path); ok {
|
if _, ok := ParseCloudLibraryMount(lib.Path); ok {
|
||||||
if err := s.repo.Media.PurgeByLibrary(ctx, id); err != nil {
|
err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
return err
|
if err := tx.Unscoped().Where("library_id = ?", id).Delete(&model.Media{}).Error; 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
|
if err := hardDeleteLibraryRoots(ctx, tx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Unscoped().Where("id = ?", id).Delete(&model.Library{}).Error
|
||||||
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.invalidateMediaCache(ctx)
|
s.invalidateMediaCache(ctx)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = s.repo.DB.WithContext(ctx).Where("library_id = ?", id).Delete(&model.LibraryRoot{}).Error
|
err = s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
if err := s.repo.Media.DeleteByLibrary(ctx, id); err != nil {
|
if err := tx.Where("library_id = ?", id).Delete(&model.Media{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = s.repo.Library.Delete(ctx, id)
|
if err := hardDeleteLibraryRoots(ctx, tx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Delete(&model.Library{}, "id = ?", id).Error
|
||||||
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.invalidateMediaCache(ctx)
|
s.invalidateMediaCache(ctx)
|
||||||
}
|
}
|
||||||
return err
|
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,6 +29,7 @@ func strmLibrarySpecificOutputDir(base string, lib *model.Library) string {
|
|||||||
if outputDir == "" || outputDir == "." || lib == nil {
|
if outputDir == "" || outputDir == "." || lib == nil {
|
||||||
return outputDir
|
return outputDir
|
||||||
}
|
}
|
||||||
|
outputDir = stripSTRMLibraryCategoryAfterRoot(outputDir)
|
||||||
subdir := strmLibraryOutputSubdir(*lib)
|
subdir := strmLibraryOutputSubdir(*lib)
|
||||||
if subdir == "" || strmPathHasSuffix(outputDir, subdir) || pathAlreadyEndsWith(outputDir, filepath.Base(subdir)) {
|
if subdir == "" || strmPathHasSuffix(outputDir, subdir) || pathAlreadyEndsWith(outputDir, filepath.Base(subdir)) {
|
||||||
return outputDir
|
return outputDir
|
||||||
@@ -36,6 +37,34 @@ func strmLibrarySpecificOutputDir(base string, lib *model.Library) string {
|
|||||||
return filepath.Join(outputDir, subdir)
|
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 {
|
func strmPathHasSuffix(pathValue, suffix string) bool {
|
||||||
pathValue = strings.Trim(filepath.ToSlash(filepath.Clean(strings.TrimSpace(pathValue))), "/")
|
pathValue = strings.Trim(filepath.ToSlash(filepath.Clean(strings.TrimSpace(pathValue))), "/")
|
||||||
suffix = strings.Trim(filepath.ToSlash(filepath.Clean(strings.TrimSpace(suffix))), "/")
|
suffix = strings.Trim(filepath.ToSlash(filepath.Clean(strings.TrimSpace(suffix))), "/")
|
||||||
|
|||||||
@@ -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) {
|
func TestGenerateSTRMForLibraryUsesCategoryDefaultOutputDir(t *testing.T) {
|
||||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{})
|
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{})
|
||||||
repos := repository.New(db)
|
repos := repository.New(db)
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export function inferSTRMOutputRoot(saved: string, libraries: Library[]) {
|
|||||||
}
|
}
|
||||||
if (slashSaved === toSlash(subdir).toLowerCase()) return ''
|
if (slashSaved === toSlash(subdir).toLowerCase()) return ''
|
||||||
}
|
}
|
||||||
|
const inferredBySTRMRoot = stripLibraryCategoryAfterSTRMRoot(normalized)
|
||||||
|
if (inferredBySTRMRoot) return inferredBySTRMRoot
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +135,20 @@ function splitPath(raw: string) {
|
|||||||
return raw.split('/').map((part) => part.trim()).filter((part) => part && part !== '.')
|
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[]) {
|
function joinPath(root: string, ...parts: string[]) {
|
||||||
return [trimPath(root), ...parts.map(trimPath)].filter(Boolean).join('/')
|
return [trimPath(root), ...parts.map(trimPath)].filter(Boolean).join('/')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,8 +99,9 @@ export function isSeriesCard(card: SeriesCard): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function seriesTitle(media: Media): string {
|
export function seriesTitle(media: Media): string {
|
||||||
|
const title = media.title?.trim()
|
||||||
const fromPath = seriesTitleFromPath(media.path)
|
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 {
|
function normalizeTitle(value?: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user