AllowedExtensions in Go was the intended single source of truth for library type file extensions, but it was never synced to the database. This caused missing extensions like .pdf for manga to be absent from library_types. - Add SyncAllowedExtensions() to sync Go AllowedExtensions map to DB - Call SyncAllowedExtensions() from cmd/server/main.go on startup - Ensure .pdf is included in manga extensions
325 lines
10 KiB
Go
325 lines
10 KiB
Go
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")
|
|
}
|