package services import ( "bookhoard/internal/database" "context" "fmt" "log" "os" "path/filepath" "strings" "github.com/jackc/pgx/v5/pgtype" ) type LibraryService struct { db *database.Queries } func NewLibraryService(db *database.Queries) *LibraryService { return &LibraryService{ db: db, } } // Library type definitions and file extensions const ( LibraryTypeEbooks = "ebooks" LibraryTypeComics = "comics" LibraryTypeManga = "manga" ) var AllowedExtensions = map[string][]string{ LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".doc", ".docx", ".lit", ".fb2", ".pdb"}, LibraryTypeComics: {".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"}, LibraryTypeManga: {".cbz", ".cbr", ".epub", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"}, } var MimeTypes = map[string]string{ // Images (manga) ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".bmp": "image/bmp", ".webp": "image/webp", ".avif": "image/avif", ".tiff": "image/tiff", ".tif": "image/tiff", // Comics ".cbz": "application/vnd.comicbook+zip", ".cbr": "application/vnd.comicbook-rar", ".cb7": "application/x-cb7", ".cbt": "application/x-cbt", // Ebooks ".epub": "application/epub+zip", ".pdf": "application/pdf", ".mobi": "application/x-mobipocket-ebook", ".azw": "application/vnd.amazon.ebook", ".azw3": "application/vnd.amazon.ebook", ".txt": "text/plain", ".rtf": "application/rtf", ".doc": "application/msword", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".lit": "application/x-msreader", ".fb2": "application/x-fictionbook+xml", ".pdb": "application/vnd.palm", } // GetLibraryTypes retrieves all available library types func (s *LibraryService) GetLibraryTypes(ctx context.Context) ([]database.LibraryTypes, error) { return s.db.GetLibraryTypes(ctx) } // CreateLibrary creates a new library with the given parameters func (s *LibraryService) CreateLibrary(ctx context.Context, name, description, libraryType string, adminID pgtype.UUID) (*database.Libraries, error) { // Get library type ID libType, err := s.db.GetLibraryTypeByName(ctx, libraryType) if err != nil { return nil, fmt.Errorf("invalid library type: %w", err) } // Create library library, err := s.db.CreateLibrary(ctx, database.CreateLibraryParams{ Name: name, Description: pgtype.Text{String: description, Valid: true}, LibraryTypeID: libType.ID, CreatedByAdminID: adminID, }) if err != nil { return nil, fmt.Errorf("failed to create library: %w", err) } return &library, nil } // GetLibrary retrieves a library by ID with type information func (s *LibraryService) GetLibrary(ctx context.Context, libraryID pgtype.UUID) (*database.GetLibraryRow, error) { library, err := s.db.GetLibrary(ctx, libraryID) if err != nil { return nil, err } return &library, nil } // ListLibraries retrieves all libraries func (s *LibraryService) ListLibraries(ctx context.Context) ([]database.ListLibrariesRow, error) { return s.db.ListLibraries(ctx) } // UpdateLibrary updates an existing library func (s *LibraryService) UpdateLibrary(ctx context.Context, libraryID pgtype.UUID, name, description string) (*database.Libraries, error) { library, err := s.db.UpdateLibrary(ctx, database.UpdateLibraryParams{ ID: libraryID, Name: name, Description: pgtype.Text{String: description, Valid: true}, }) if err != nil { return nil, err } return &library, nil } // DeleteLibrary deletes a library and all its associated data func (s *LibraryService) DeleteLibrary(ctx context.Context, libraryID pgtype.UUID) error { return s.db.DeleteLibrary(ctx, libraryID) } // AddLibraryFolder adds a folder to a library func (s *LibraryService) AddLibraryFolder(ctx context.Context, libraryID pgtype.UUID, folderPath string) (*database.LibraryFolders, error) { folder, err := s.db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{ LibraryID: libraryID, FolderPath: folderPath, }) if err != nil { return nil, err } return &folder, nil } // GetLibraryFolders retrieves all folders for a library func (s *LibraryService) GetLibraryFolders(ctx context.Context, libraryID pgtype.UUID) ([]database.LibraryFolders, error) { return s.db.GetLibraryFolders(ctx, libraryID) } // DeleteLibraryFolder removes a folder from a library func (s *LibraryService) DeleteLibraryFolder(ctx context.Context, libraryID pgtype.UUID, folderPath string) error { _, err := s.db.DeleteLibraryFolder(ctx, database.DeleteLibraryFolderParams{ LibraryID: libraryID, FolderPath: folderPath, }) return err } // SetLibraryVisibility sets library visibility for a user func (s *LibraryService) SetLibraryVisibility(ctx context.Context, userID, libraryID pgtype.UUID, isVisible bool) (*database.LibraryVisibility, error) { visibility, err := s.db.SetLibraryVisibility(ctx, database.SetLibraryVisibilityParams{ UserID: userID, LibraryID: libraryID, IsVisible: isVisible, }) if err != nil { return nil, err } return &visibility, nil } // GetUserVisibleLibraries retrieves all libraries visible to a user func (s *LibraryService) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]database.GetUserVisibleLibrariesRow, error) { return s.db.GetUserVisibleLibraries(ctx, userID) } // IsFileExtensionAllowed checks if a file extension is allowed for a library type func (s *LibraryService) IsFileExtensionAllowed(libraryType, extension string) bool { extensions, exists := AllowedExtensions[libraryType] if !exists { return false } for _, ext := range extensions { if strings.EqualFold(ext, extension) { return true } } return false } // GetLibraryFileExtensions returns all allowed file extensions for a library type func (s *LibraryService) GetLibraryFileExtensions(libraryType string) []string { extensions, exists := AllowedExtensions[libraryType] if !exists { return []string{} } return extensions } // GetLibraryTypeFromFileExtension determines the library type based on file extension func (s *LibraryService) GetLibraryTypeFromFileExtension(filename string) string { ext := strings.ToLower(filepath.Ext(filename)) for libType, extensions := range AllowedExtensions { for _, allowedExt := range extensions { if ext == allowedExt { return libType } } } return "" } // ValidateLibraryPath checks if a path is valid for the given library type func (s *LibraryService) ValidateLibraryPath(libraryType, folderPath string) error { // You could add more validation here like: // - Check if path exists // - Check if path is readable // - Validate path format for specific library types // - Check for appropriate file structures (e.g., for manga with image folders) return nil } // GetLibraryStats returns statistics for a library (media count, etc.) func (s *LibraryService) GetLibraryStats(ctx context.Context, libraryID pgtype.UUID) (map[string]interface{}, error) { // For now, return basic info. This can be expanded with more detailed stats mediaItems, err := s.db.ListMediaItemsByLibrary(ctx, libraryID) if err != nil { return nil, err } return map[string]interface{}{ "media_count": len(mediaItems), }, nil } // HasFolders checks if a library has at least one folder configured func (s *LibraryService) HasFolders(ctx context.Context, libraryID pgtype.UUID) (bool, error) { folders, err := s.db.GetLibraryFolders(ctx, libraryID) if err != nil { return false, fmt.Errorf("failed to check library folders: %w", err) } return len(folders) > 0, nil } // BrowseDirectories lists directories at a given path for folder browser UI // Returns: (directories, currentPath, parentPath, error) func (s *LibraryService) BrowseDirectories(ctx context.Context, path string) ([]string, string, string, error) { // Security: path traversal protection if strings.Contains(path, "..") { return nil, "", "", fmt.Errorf("path traversal not allowed") } cleanPath := filepath.Clean(path) // Check if path exists and is accessible fileInfo, err := os.Stat(cleanPath) if err != nil { if os.IsNotExist(err) { return nil, "", "", fmt.Errorf("path does not exist") } return nil, "", "", fmt.Errorf("path not accessible: %w", err) } if !fileInfo.IsDir() { return nil, "", "", fmt.Errorf("not a directory") } // Read directory contents entries, err := os.ReadDir(cleanPath) if err != nil { return nil, "", "", fmt.Errorf("failed to read directory: %w", err) } // Filter only directories var dirs []string for _, entry := range entries { if entry.IsDir() { dirs = append(dirs, entry.Name()) } } // Get parent path for navigation parentPath := filepath.Dir(cleanPath) if parentPath == cleanPath { parentPath = "" // At root } return dirs, cleanPath, parentPath, nil } // SyncAllowedExtensions syncs the Go AllowedExtensions map into the database. // This ensures library_types.allowed_extensions stays in sync with the Go source of truth. func (s *LibraryService) SyncAllowedExtensions(ctx context.Context) { for typeName, exts := range AllowedExtensions { if err := s.db.SyncLibraryTypeExtensions(ctx, database.SyncLibraryTypeExtensionsParams{ Name: typeName, AllowedExtensions: exts, }); err != nil { log.Printf("Warning: failed to sync allowed extensions for library type %s: %v", typeName, err) } } } func (s *LibraryService) ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) { // Get library folders for this library folders, err := s.db.GetLibraryFolders(ctx, libraryID) if err != nil || len(folders) == 0 { return "", fmt.Errorf("no library folders found for library") } // Try each folder - find one where the relative path makes sense for _, folder := range folders { fullPath := filepath.Join(folder.FolderPath, relativePath) if _, err := os.Stat(fullPath); err == nil { return fullPath, nil } } // Fallback: use first folder (file might not exist yet during scan) if len(folders) > 0 { return filepath.Join(folders[0].FolderPath, relativePath), nil } return "", fmt.Errorf("could not resolve path") }