Files
bookhoard/internal/services/library_service.go
T
john-okeefe dea952020c fix(library): sync allowed extensions from Go source of truth to DB on startup
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
2026-05-16 19:30:46 -04:00

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")
}