Add .epub and .pdf to comics type, add .pdf to manga type, and ensure avif/tiff/tif extensions are consistently included in manga across all layers. Update test fixtures to match the canonical extension lists.
311 lines
9.4 KiB
Go
311 lines
9.4 KiB
Go
package services
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"context"
|
|
"fmt"
|
|
"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
|
|
}
|
|
|
|
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")
|
|
}
|