package service import ( "context" "errors" "fmt" "os" "path/filepath" "strings" "github.com/truewhile/MeBox/internal/model" ) type LibraryRootInput struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Path string `json:"path"` Enabled *bool `json:"enabled,omitempty"` SortOrder *int `json:"sort_order,omitempty"` } // CreateLibrary persists a library after validating that its path exists. func (s *MediaService) CreateLibrary(ctx context.Context, name, path, kind string) (*model.Library, error) { return s.CreateLibraryWithRoots(ctx, name, kind, []LibraryRootInput{{Path: path}}) } func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind string, inputs []LibraryRootInput) (*model.Library, error) { return s.CreateLibraryWithRootsAndCover(ctx, name, kind, "", inputs) } func (s *MediaService) CreateLibraryWithRootsAndCover(ctx context.Context, name, kind, coverURL string, inputs []LibraryRootInput) (*model.Library, error) { if strings.TrimSpace(name) == "" { return nil, errors.New("name required") } roots, err := normalizeLibraryRootInputs(inputs, true) if err != nil { return nil, err } kind = inferLibraryKind(name, roots[0].Path, kind) if existing, err := s.findLogicalLibrary(ctx, name, kind); err != nil { return nil, err } else if existing != nil { if strings.TrimSpace(coverURL) != "" { if err := s.UpdateLibraryCover(ctx, existing.ID, coverURL); err != nil { return nil, err } } lib, err := s.appendLibraryRoots(ctx, existing, roots) if err != nil { return nil, err } s.invalidateMediaCache(ctx) return lib, nil } lib := &model.Library{ Name: strings.TrimSpace(name), Path: roots[0].Path, Type: kind, CoverURL: strings.TrimSpace(coverURL), Enabled: true, CarouselEnabled: false, } if err := s.repo.Library.CreateWithRoots(ctx, lib, roots); err != nil { return nil, err } s.invalidateMediaCache(ctx) return lib, nil } // CreateLibrariesPerSubfolder 为 parent 目录下的每个直接子目录各建一个媒体库, // 媒体库名取子目录名,路径指向该子目录。kind 为空时按子目录名推断类型。 func (s *MediaService) CreateLibrariesPerSubfolder(ctx context.Context, parent, kind, coverURL string) ([]model.Library, error) { parent = strings.TrimSpace(parent) if parent == "" { return nil, errors.New("parent path required") } dir, err := resolveAccessibleLibraryPath(parent) if err != nil { return nil, err } entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("read directory failed: %w", err) } subdirs := make([]string, 0, len(entries)) for _, entry := range entries { if !entry.IsDir() { continue } if strings.HasPrefix(entry.Name(), ".") { continue } subdirs = append(subdirs, filepath.Join(dir, entry.Name())) } if len(subdirs) == 0 { return nil, errors.New("no subfolders found") } created := make([]model.Library, 0, len(subdirs)) for _, subdir := range subdirs { name := filepath.Base(subdir) lib, err := s.CreateLibraryWithRootsAndCover(ctx, name, kind, coverURL, []LibraryRootInput{{Path: subdir}}) if err != nil { return nil, fmt.Errorf("create library for %s: %w", subdir, err) } created = append(created, *lib) } return created, nil } func (s *MediaService) UpdateLibraryCover(ctx context.Context, libraryID, coverURL string) error { return s.repo.DB.WithContext(ctx).Model(&model.Library{}).Where("id = ?", libraryID). Update("cover_url", strings.TrimSpace(coverURL)).Error } // UpdateLibraryFields updates sort_order / carousel_enabled on a library. func (s *MediaService) UpdateLibraryFields(ctx context.Context, libraryID string, sortOrder *int, carouselEnabled *bool) error { updates := map[string]any{} if sortOrder != nil { updates["sort_order"] = *sortOrder } if carouselEnabled != nil { updates["carousel_enabled"] = *carouselEnabled } if len(updates) == 0 || strings.TrimSpace(libraryID) == "" { return nil } return s.repo.DB.WithContext(ctx).Model(&model.Library{}). Where("id = ?", libraryID).Updates(updates).Error } // ReorderLibraries persists a full media-library ordering. func (s *MediaService) ReorderLibraries(ctx context.Context, ids []string) error { return s.repo.Library.SetSortOrder(ctx, ids) } func (s *MediaService) findLogicalLibrary(ctx context.Context, name, kind string) (*model.Library, error) { if s == nil || s.repo == nil || s.repo.Library == nil { return nil, nil } libs, err := s.repo.Library.List(ctx) if err != nil { return nil, err } nameKey := strings.ToLower(strings.TrimSpace(name)) typeKey := strings.ToLower(strings.TrimSpace(kind)) for i := range libs { if strings.ToLower(strings.TrimSpace(libs[i].Name)) == nameKey && strings.ToLower(strings.TrimSpace(libs[i].Type)) == typeKey { return &libs[i], nil } } return nil, nil } func (s *MediaService) appendLibraryRoots(ctx context.Context, lib *model.Library, roots []model.LibraryRoot) (*model.Library, error) { if lib == nil { return nil, errors.New("library not found") } if err := s.ensureLibraryRoots(ctx, lib.ID); err != nil { return nil, err } existing, err := s.repo.Library.ListRoots(ctx, lib.ID) if err != nil { return nil, err } for i := range roots { root := roots[i] root.LibraryID = lib.ID root.SortOrder = len(existing) + i if err := s.ensureLibraryRootPathUnique(ctx, lib.ID, "", root.Path); err != nil { return nil, err } if err := s.repo.Library.CreateRoot(ctx, &root); err != nil { return nil, err } } if err := s.syncLibraryPrimaryRoot(ctx, lib.ID); err != nil { return nil, err } return s.repo.Library.FindByID(ctx, lib.ID) } func normalizeLibraryRootInputs(inputs []LibraryRootInput, requirePath bool) ([]model.LibraryRoot, error) { roots := make([]model.LibraryRoot, 0, len(inputs)) seen := map[string]struct{}{} for i, input := range inputs { rawPath := strings.TrimSpace(input.Path) if rawPath == "" { if requirePath { return nil, errors.New("at least one path required") } continue } abs, err := normalizeLibraryRootPath(rawPath) if err != nil { return nil, err } key := libraryRootPathKey(abs) if _, ok := seen[key]; ok { return nil, fmt.Errorf("duplicate library path: %s", abs) } seen[key] = struct{}{} enabled := true if input.Enabled != nil { enabled = *input.Enabled } roots = append(roots, model.LibraryRoot{ Name: strings.TrimSpace(input.Name), Path: abs, Enabled: enabled, }) if input.SortOrder != nil { roots[len(roots)-1].SortOrder = *input.SortOrder } else { roots[len(roots)-1].SortOrder = i } } if len(roots) == 0 && requirePath { return nil, errors.New("at least one path required") } return roots, nil } func (s *MediaService) ListLibraryRoots(ctx context.Context, libraryID string) ([]model.LibraryRoot, error) { if err := s.ensureLibraryRoots(ctx, libraryID); err != nil { return nil, err } return s.repo.Library.ListRoots(ctx, libraryID) } func (s *MediaService) AddLibraryRoot(ctx context.Context, libraryID string, input LibraryRootInput) (*model.LibraryRoot, error) { lib, err := s.repo.Library.FindByID(ctx, libraryID) if err != nil { return nil, err } if lib == nil { return nil, errors.New("library not found") } roots, err := normalizeLibraryRootInputs([]LibraryRootInput{input}, true) if err != nil { return nil, err } root := roots[0] root.LibraryID = libraryID if err := s.ensureLibraryRootPathUnique(ctx, libraryID, "", root.Path); err != nil { return nil, err } if root.SortOrder == 0 { existing, _ := s.repo.Library.ListRoots(ctx, libraryID) root.SortOrder = len(existing) } if err := s.repo.Library.CreateRoot(ctx, &root); err != nil { return nil, err } if strings.TrimSpace(lib.Path) == "" { _ = s.repo.DB.WithContext(ctx).Model(&model.Library{}).Where("id = ?", libraryID).Update("path", root.Path).Error } return &root, nil } func (s *MediaService) UpdateLibraryRoot(ctx context.Context, libraryID, rootID string, input LibraryRootInput) (*model.LibraryRoot, error) { root, err := s.repo.Library.FindRootByID(ctx, libraryID, rootID) if err != nil || root == nil { return root, err } updates := map[string]any{} if input.Name != "" { updates["name"] = strings.TrimSpace(input.Name) } if strings.TrimSpace(input.Path) != "" { roots, err := normalizeLibraryRootInputs([]LibraryRootInput{input}, true) if err != nil { return nil, err } if err := s.ensureLibraryRootPathUnique(ctx, libraryID, rootID, roots[0].Path); err != nil { return nil, err } updates["path"] = roots[0].Path root.Path = roots[0].Path } if input.Enabled != nil { updates["enabled"] = *input.Enabled root.Enabled = *input.Enabled } if input.SortOrder != nil { updates["sort_order"] = *input.SortOrder root.SortOrder = *input.SortOrder } if err := s.repo.Library.UpdateRoot(ctx, root, updates); err != nil { return nil, err } if err := s.syncLibraryPrimaryRoot(ctx, libraryID); err != nil { return nil, err } return s.repo.Library.FindRootByID(ctx, libraryID, rootID) } func (s *MediaService) DeleteLibraryRoot(ctx context.Context, libraryID, rootID string) error { root, err := s.repo.Library.FindRootByID(ctx, libraryID, rootID) if err != nil { return err } if root == nil { return errors.New("library root not found") } roots, err := s.repo.Library.ListRoots(ctx, libraryID) if err != nil { return err } if len(roots) <= 1 { return errors.New("library must keep at least one path") } if err := s.repo.Media.DeleteByLibraryRoot(ctx, libraryID, rootID); err != nil { return err } if err := s.repo.Library.DeleteRoot(ctx, libraryID, rootID); err != nil { return err } return s.syncLibraryPrimaryRoot(ctx, libraryID) } func (s *MediaService) ensureLibraryRoots(ctx context.Context, libraryID string) error { lib, err := s.repo.Library.FindByID(ctx, libraryID) if err != nil || lib == nil || len(lib.Roots) > 0 || strings.TrimSpace(lib.Path) == "" { return err } root := &model.LibraryRoot{ LibraryID: libraryID, Name: libraryRootNameForPath(lib.Path), Path: lib.Path, Enabled: lib.Enabled, SortOrder: 0, } return s.repo.Library.CreateRoot(ctx, root) } func (s *MediaService) syncLibraryPrimaryRoot(ctx context.Context, libraryID string) error { roots, err := s.repo.Library.ListRoots(ctx, libraryID) if err != nil || len(roots) == 0 { return err } return s.repo.DB.WithContext(ctx).Model(&model.Library{}).Where("id = ?", libraryID).Update("path", roots[0].Path).Error } func (s *MediaService) ensureLibraryRootPathUnique(ctx context.Context, libraryID, exceptRootID, pathValue string) error { roots, err := s.repo.Library.ListRoots(ctx, libraryID) if err != nil { return err } key := strings.ToLower(filepath.Clean(strings.TrimSpace(pathValue))) key = libraryRootPathKey(pathValue) for _, existing := range roots { if existing.ID == exceptRootID { continue } if libraryRootPathKey(existing.Path) == key { return fmt.Errorf("duplicate library path: %s", pathValue) } } return nil } func normalizeLibraryRootPath(rawPath string) (string, error) { rawPath = strings.TrimSpace(rawPath) return resolveAccessibleLibraryPath(rawPath) } func libraryRootPathKey(pathValue string) string { pathValue = strings.TrimSpace(pathValue) return strings.ToLower(filepath.Clean(pathValue)) } func libraryRootNameForPath(pathValue string) string { return filepath.Base(filepath.Clean(pathValue)) }