diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 023caf6..b0eb38b 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -1,3 +1,6 @@ +// Package handlers provides HTTP request/response handlers for the bookhoard application. +// It includes handlers for authentication, libraries, media items, reading, collections, +// dashboards, devices, analytics, and various system features. package handlers import ( diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index ce66543..d2ae7a3 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -1,3 +1,5 @@ +// Package services provides the core business logic layer for bookhoard, +// including media scanning, library management, search, analytics, and conversion services. package services import ( @@ -58,12 +60,12 @@ type MediaMetadata struct { FileHashInfo *HashInfo FileFormats []*FormatInfo - // NEW: Reading direction fields (from ComicInfo.xml or computed) + // Reading direction fields (from ComicInfo.xml or computed) MangaType string // Raw ComicInfo.xml Manga field ReadingDirection string // Computed: auto, ltr, rtl, vertical Language string // ISO 639-1 language code - // NEW: Additional metadata fields (from ComicInfo.xml or other metadata sources) + // Additional metadata fields (from ComicInfo.xml or other metadata sources) // Universal fields (apply to ebooks, audiobooks, comics) SeriesCount int32 // Total items in series (Count field for comics, series count for books) Volume int32 // Volume/omnibus number @@ -110,7 +112,7 @@ type MediaScanner struct { dirtyDirsMu sync.RWMutex fileStability map[string]*atomic.Bool fileStabilityMu sync.RWMutex - scan_mutex sync.Mutex + scanMutex sync.Mutex scanInProgress atomic.Bool pollInterval time.Duration watching atomic.Bool @@ -243,7 +245,11 @@ func (s *MediaScanner) SetFolders(folders []string) error { // Remove old watch if exists if s.watcher != nil { - s.watcher.Close() + if s.watcher != nil { + if err := s.watcher.Close(); err != nil { + fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err) + } + } } // Create new watcher @@ -298,12 +304,15 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error { s.errors = 0 for _, folder := range s.folders { - filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error { + if err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error { if !d.IsDir() && s.isScannableFile(path) { s.totalFiles++ } return nil - }) + }); err != nil { + fmt.Printf("Warning: failed to count files in %s: %v\n", folder, err) + return nil + } } fmt.Printf("Starting scan of %d folders: %v (%d files to scan)\n", len(s.folders), s.folders, s.totalFiles) @@ -383,7 +392,7 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error { // Build set of scanned file paths for this folder scannedPaths := make(map[string]bool) - filepath.WalkDir(folder, func(path string, d os.DirEntry, walkErr error) error { + if err := filepath.WalkDir(folder, func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } @@ -391,7 +400,10 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error { scannedPaths[s.getRelativePath(path)] = true } return nil - }) + }); err != nil { + fmt.Printf("[RESCAN-CLEANUP] Warning: failed to walk directory %s, skipping orphan cleanup: %v\n", folder, err) + continue // Skip to next folder to avoid false deletions + } // Delete items whose files no longer exist - with safety logging for _, item := range dbItems { @@ -596,7 +608,6 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool, if err != nil { fmt.Printf("Warning: failed to extract hash info from %s: %v\n", path, err) hashInfo = &HashInfo{} - formatInfo = &FormatInfo{} } else { metadata.FileHashInfo = hashInfo metadata.FileFormats = []*FormatInfo{formatInfo} @@ -855,7 +866,7 @@ func (s *MediaScanner) mergeMetadata(path string, calibreMetadata *MediaMetadata // Alternate series information (store as JSONB string) if metadata.AlternateInfo == "" && (comicInfo.AlternateSeries != "" || comicInfo.AlternateNumber > 0) { - alternateData := map[string]interface{}{} + alternateData := map[string]any{} if comicInfo.AlternateSeries != "" { alternateData["alternate_series"] = comicInfo.AlternateSeries } @@ -1068,29 +1079,31 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) { case ".epub": metadata, err := s.extractEPUBMetadata(path) if err != nil { - // Enhanced format detection for EPUBs - isFixedLayout, detectErr := s.DetectFixedLayoutEPUB(path) - if detectErr == nil && isFixedLayout { - // Override format group for manga EPUBs - metadata.FileFormats = []*FormatInfo{{ - FormatType: "fixed_layout", - FilePath: path, - MimeType: mimeType, - }} - } - // Try to extract embedded cover - coverPath, err := s.extractEPUBCover(path) - if err != nil { - fmt.Printf("Warning: failed to extract EPUB cover from %s: %v\n", path, err) - } else if coverPath != "" { - metadata.CoverPath = s.getRelativePath(coverPath) - } - // If no embedded cover, try sidecar - if metadata.CoverPath == "" { - sidecarCover := findSidecarCover(path) - if sidecarCover != "" { - metadata.CoverPath = s.getRelativePath(sidecarCover) + // Enhanced format detection for EPUBs + isFixedLayout, detectErr := s.DetectFixedLayoutEPUB(path) + if detectErr == nil && isFixedLayout { + // Override format group for manga EPUBs + metadata.FileFormats = []*FormatInfo{{ + FormatType: "fixed_layout", + FilePath: path, + MimeType: s.getMimeType(path), + }} } + // Try to extract embedded cover + coverPath, err := s.extractEPUBCover(path) + if err != nil { + fmt.Printf("Warning: failed to extract EPUB cover from %s: %v\n", path, err) + } else if coverPath != "" { + metadata.CoverPath = s.getRelativePath(coverPath) + } + // If no embedded cover, try sidecar + if metadata.CoverPath == "" { + sidecarCover := findSidecarCover(path) + if sidecarCover != "" { + metadata.CoverPath = s.getRelativePath(sidecarCover) + } + } + return metadata, nil } return metadata, nil case ".pdf": @@ -1197,7 +1210,11 @@ func (s *MediaScanner) DetectFixedLayoutEPUB(epubPath string) (bool, error) { if err != nil { return false, fmt.Errorf("failed to open EPUB: %w", err) } - defer r.Close() + defer func() { + if err := r.Close(); err != nil { + fmt.Printf("Warning: failed to close EPUB file %s: %v\n", epubPath, err) + } + }() // Find and read OPF file var opfFile *zip.File @@ -1222,7 +1239,11 @@ func (s *MediaScanner) DetectFixedLayoutEPUB(epubPath string) (bool, error) { if err != nil { return false, fmt.Errorf("failed to open OPF: %w", err) } - defer rc.Close() + defer func() { + if err := rc.Close(); err != nil { + fmt.Printf("Warning: failed to close OPF file reader from %s: %v\n", epubPath, err) + } + }() opfContent, err := io.ReadAll(rc) if err != nil { @@ -1288,12 +1309,16 @@ func (s *MediaScanner) ValidateMediaItemForLibrary( // Set manga-specific flags for fixed-layout EPUBs if mediaItem.FormatGroup == "fixed_layout" { // Default manga type if not set - if mediaItem.MangaType == "unknown" || mediaItem.MangaType == "" { - mediaItem.MangaType = "yes" + if !mediaItem.MangaType.Valid || + mediaItem.MangaType.String == "unknown" || + mediaItem.MangaType.String == "" { + mediaItem.MangaType = pgtype.Text{String: "yes", Valid: true} } // Default reading direction for manga - if mediaItem.ReadingDirection == "auto" || mediaItem.ReadingDirection == "" { - mediaItem.ReadingDirection = "rtl" + if !mediaItem.ReadingDirection.Valid || + mediaItem.ReadingDirection.String == "auto" || + mediaItem.ReadingDirection.String == "" { + mediaItem.ReadingDirection = pgtype.Text{String: "rtl", Valid: true} } } } @@ -1316,7 +1341,7 @@ func (s *MediaScanner) ValidateMediaItemForLibrary( if libraryTypeName == "ebooks" { // Flag manga for potential reorganization (info level) if mediaItem.FormatGroup == "fixed_layout" && - (mediaItem.MangaType == "yes" || mediaItem.MangaType == "yes_and_right_to_left") { + (!mediaItem.MangaType.Valid || mediaItem.MangaType.String == "yes" || mediaItem.MangaType.String == "yes_and_right_to_left") { msg := fmt.Sprintf( "File '%s' appears to be manga (fixed-layout with images). "+ "Consider moving to a manga or comics library for better organization.", @@ -1355,7 +1380,11 @@ func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata, if err != nil { return nil, fmt.Errorf("failed to open metadata.opf: %v", err) } - defer file.Close() + defer func() { + if err := file.Close(); err != nil { + fmt.Printf("Warning: failed to close metadata.opf: %v\n", err) + } + }() // Define XML structure for parsing with full Dublin Core namespace URLs var opf struct { XMLName xml.Name `xml:"package"` @@ -1467,7 +1496,11 @@ func (s *MediaScanner) extractEPUBCover(epubPath string) (string, error) { if err != nil { return "", fmt.Errorf("failed to open EPUB as zip: %v", err) } - defer r.Close() + defer func() { + if err := r.Close(); err != nil { + fmt.Printf("Warning: failed to close EPUB zip reader for %s: %v\n", epubPath, err) + } + }() // Try to find cover image from OPF metadata coverImageName := "" @@ -1481,9 +1514,11 @@ func (s *MediaScanner) extractEPUBCover(epubPath string) (string, error) { if err != nil { continue } - content, err := io.ReadAll(rc) - rc.Close() - if err != nil { + content, readErr := io.ReadAll(rc) + if closeErr := rc.Close(); closeErr != nil { + fmt.Printf("Warning: failed to close META-INF/container.xml reader in %s: %v\n", epubPath, closeErr) + } + if readErr != nil { continue } // Parse container.xml to find OPF path @@ -1583,8 +1618,7 @@ func findCoverInOPF(opfContent []byte, files []*zip.File, opfDir string) string if len(metaMatches) > 1 { coverContent := metaMatches[1] // Could be "image-id" format - if strings.HasPrefix(coverContent, "image-") { - coverID := strings.TrimPrefix(coverContent, "image-") + if coverID, found := strings.CutPrefix(coverContent, "image-"); found { hrefRE := regexp.MustCompile(fmt.Sprintf(`]*id="%s"[^>]*href="([^"]+)"`, coverID)) hrefMatches := hrefRE.FindStringSubmatch(contentStr) if len(hrefMatches) > 1 { @@ -1622,7 +1656,11 @@ func readFileFromZip(files []*zip.File, name string) ([]byte, error) { if err != nil { return nil, err } - defer rc.Close() + defer func() { + if err := rc.Close(); err != nil { + fmt.Printf("Warning: failed to close zip file reader for %s: %v\n", name, err) + } + }() return io.ReadAll(rc) } } @@ -1638,7 +1676,11 @@ func extractImageFromZip(files []*zip.File, imagePath, opfDir string) ([]byte, e if err != nil { return nil, err } - defer rc.Close() + defer func() { + if err := rc.Close(); err != nil { + fmt.Printf("Warning: failed to close zip image reader for %s: %v\n", imagePath, err) + } + }() return io.ReadAll(rc) } } @@ -1651,7 +1693,11 @@ func extractImageFromZip(files []*zip.File, imagePath, opfDir string) ([]byte, e if err != nil { return nil, err } - defer rc.Close() + defer func() { + if err := rc.Close(); err != nil { + fmt.Printf("Warning: failed to close zip image reader for resolved path %s: %v\n", resolvedPath, err) + } + }() return io.ReadAll(rc) } } @@ -1720,7 +1766,11 @@ func (s *MediaScanner) extractPDFMetadata(path string) (*MediaMetadata, error) { metadata.Title = strings.TrimSuffix(filepath.Base(path), ".pdf") return metadata, nil } - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + fmt.Printf("Warning: failed to close PDF file %s: %v\n", path, err) + } + }() // Use pdfcpu API to read PDF metadata // Configuration: nil = default (lenient mode) @@ -1787,7 +1837,11 @@ func (s *MediaScanner) extractPDFCover(pdfPath string) (string, error) { if err != nil { return "", fmt.Errorf("failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + fmt.Printf("Warning: failed to remove temp directory %s: %v\n", tmpDir, err) + } + }() // Use pdfcpu API to extract images from first page // ExtractImagesFile(inFile, outDir string, selectedPages []string, conf *model.Configuration) error @@ -1927,7 +1981,9 @@ func extractMetadataFromArchive(files []archiveFile) (*ComicInfo, []byte, error) } data, err := io.ReadAll(rc) - rc.Close() + if closeErr := rc.Close(); closeErr != nil { + fmt.Printf("Warning: failed to close ComicInfo.xml reader: %v\n", closeErr) + } if err != nil { return nil, nil, fmt.Errorf("failed to read ComicInfo.xml: %w", err) } @@ -1947,7 +2003,9 @@ func extractMetadataFromArchive(files []archiveFile) (*ComicInfo, []byte, error) } coverImage, err = io.ReadAll(rc) - rc.Close() + if closeErr := rc.Close(); closeErr != nil { + fmt.Printf("Warning: failed to close cover image reader for %s: %v\n", f.Name(), closeErr) + } if err == nil { _, _, err = image.Decode(bytes.NewReader(coverImage)) if err != nil { @@ -1971,7 +2029,11 @@ func extractZipMetadata(filePath string) (*ComicInfo, []byte, error) { if err != nil { return nil, nil, fmt.Errorf("failed to open ZIP archive: %w", err) } - defer r.Close() + defer func() { + if err := r.Close(); err != nil { + fmt.Printf("Warning: failed to close ZIP archive reader for %s: %v\n", filePath, err) + } + }() files := make([]archiveFile, 0, len(r.File)) for _, f := range r.File { @@ -2010,7 +2072,11 @@ func extractRarMetadata(filePath string) (*ComicInfo, []byte, error) { if err != nil { return nil, nil, fmt.Errorf("failed to open RAR archive: %w", err) } - defer r.Close() + defer func() { + if err := r.Close(); err != nil { + fmt.Printf("Warning: failed to close RAR archive reader for %s: %v\n", filePath, err) + } + }() files := make([]archiveFile, 0, 100) for { @@ -2065,7 +2131,11 @@ func extract7ZipMetadata(filePath string) (*ComicInfo, []byte, error) { if err != nil { return nil, nil, fmt.Errorf("failed to open 7-Zip archive: %w", err) } - defer r.Close() + defer func() { + if err := r.Close(); err != nil { + fmt.Printf("Warning: failed to close 7-Zip archive reader for %s: %v\n", filePath, err) + } + }() files := make([]archiveFile, 0, len(r.File)) for _, f := range r.File { @@ -2104,7 +2174,11 @@ func extractTarMetadata(filePath string) (*ComicInfo, []byte, error) { if err != nil { return nil, nil, fmt.Errorf("failed to open TAR archive: %w", err) } - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + fmt.Printf("Warning: failed to close TAR archive file %s: %v\n", filePath, err) + } + }() var tarReader *tar.Reader if strings.HasSuffix(strings.ToLower(filePath), ".tar.gz") || @@ -2113,7 +2187,11 @@ func extractTarMetadata(filePath string) (*ComicInfo, []byte, error) { if err != nil { return nil, nil, fmt.Errorf("failed to create gzip reader: %w", err) } - defer gzReader.Close() + defer func() { + if err := gzReader.Close(); err != nil { + fmt.Printf("Warning: failed to close gzip reader for %s: %v\n", filePath, err) + } + }() tarReader = tar.NewReader(gzReader) } else if strings.HasSuffix(strings.ToLower(filePath), ".tar.bz2") || strings.HasSuffix(strings.ToLower(filePath), ".tbz2") { @@ -2178,7 +2256,7 @@ func isImageFile(filename string) bool { return ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" } -func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.UUID, path string, info os.FileInfo) error { +func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.UUID, path string, _ os.FileInfo) error { // Re-extract metadata for the update metadata, err := s.extractMetadata(path) if err != nil { @@ -2261,7 +2339,9 @@ func (s *MediaScanner) WatchChanges(ctx context.Context) error { // Handle new directories - add them to the watcher if event.Has(fsnotify.Create) { if info, err := os.Stat(event.Name); err == nil && info.IsDir() { - s.watcher.Add(event.Name) + if err := s.watcher.Add(event.Name); err != nil { + fmt.Printf("Warning: failed to watch new directory %s: %v\n", event.Name, err) + } } } @@ -2283,56 +2363,6 @@ func (s *MediaScanner) WatchChanges(ctx context.Context) error { }() return nil } -func (s *MediaScanner) handleFileAdd(ctx context.Context, filePath string) { - fmt.Printf("New/modified media file detected: %s\n", filePath) - if _, err := s.processMediaFile(ctx, filePath); err != nil { - fmt.Printf("Error processing modified media file %s: %v\n", filePath, err) - } -} -func (s *MediaScanner) handleFileDelete(ctx context.Context, filePath string) { - s.logger.LogDelete(fmt.Sprintf("[WATCH-DELETE] File removed from filesystem: %s", filePath)) - // Determine libraryID for this file - var libraryID pgtype.UUID - for _, folder := range s.folders { - if strings.HasPrefix(filePath, folder) { - lib, err := s.db.GetLibraryByFolder(ctx, folder) - if err == nil { - libraryID = lib.LibraryID - break - } - } - } - if !libraryID.Valid { - msg := fmt.Sprintf("[WATCH-DELETE] WARNING: could not determine library for deleted file: %s", filePath) - s.logger.LogDelete(msg) - s.logger.LogError(msg) - return - } - // Look up media item - existingItem, err := s.db.GetMediaItemByFilePath(ctx, database.GetMediaItemByFilePathParams{ - FilePath: s.getRelativePath(filePath), - LibraryID: libraryID, - }) - if err == nil { - msg := fmt.Sprintf("[WATCH-DELETE] Found media item to delete: ID=%s, Title=%s, Path=%s", - existingItem.ID, existingItem.Title, existingItem.FilePath) - s.logger.LogDelete(msg) - if err := s.db.DeleteMediaItem(ctx, existingItem.ID); err != nil { - errMsg := fmt.Sprintf("[WATCH-DELETE] ERROR: failed to delete media item %s: %v", existingItem.ID, err) - s.logger.LogDelete(errMsg) - s.logger.LogError(errMsg) - } else { - s.logger.LogDelete(fmt.Sprintf("[WATCH-DELETE] SUCCESS: deleted media item '%s' (was at %s)", - existingItem.Title, existingItem.FilePath)) - } - } else if err != pgx.ErrNoRows { - errMsg := fmt.Sprintf("[WATCH-DELETE] ERROR: failed to look up media item for %s: %v", filePath, err) - s.logger.LogDelete(errMsg) - s.logger.LogError(errMsg) - } else { - s.logger.LogDelete(fmt.Sprintf("[WATCH-DELETE] No media item found in database for deleted file: %s", filePath)) - } -} func (s *MediaScanner) markDirectoryDirty(dirPath string) { s.dirtyDirsMu.Lock() @@ -2423,7 +2453,7 @@ func (s *MediaScanner) processDirtyDirectories(ctx context.Context) { job := &Job{ ID: uuid.New().String(), Type: JobTypeDirectoryScan, - Params: map[string]interface{}{ + Params: map[string]any{ "directory": dirPath, "db": s.db, }, @@ -2524,8 +2554,8 @@ func (s *MediaScanner) waitForFileStability(filePath string) bool { func (s *MediaScanner) scanDirectory(ctx context.Context, dirPath string) { // Prevent concurrent scans of ANY directory // Simple mutex is enough - job queue already serializes by directory - s.scan_mutex.Lock() - defer s.scan_mutex.Unlock() + s.scanMutex.Lock() + defer s.scanMutex.Unlock() s.scanInProgress.Store(true) defer s.scanInProgress.Store(false) @@ -2550,7 +2580,7 @@ func (s *MediaScanner) scanDirectory(ctx context.Context, dirPath string) { } // Walk directory and process new files - filepath.WalkDir(dirPath, func(path string, d os.DirEntry, err error) error { + if err := filepath.WalkDir(dirPath, func(path string, d os.DirEntry, err error) error { if err != nil { return err } @@ -2584,7 +2614,9 @@ func (s *MediaScanner) scanDirectory(ctx context.Context, dirPath string) { } return nil - }) + }); err != nil { + fmt.Printf("Warning: failed to walk directory %s: %v\n", dirPath, err) + } } // performInitialScan scans all root folders on startup @@ -2593,6 +2625,12 @@ func (s *MediaScanner) performInitialScan(ctx context.Context) { fmt.Printf("Performing initial scan of root folders...\n") for _, folder := range s.folders { + select { + case <-ctx.Done(): + fmt.Printf("Initial scan cancelled\n") + return + default: + } // Skip if folder doesn't exist if _, err := os.Stat(folder); os.IsNotExist(err) { fmt.Printf("Skipping nonexistent folder: %s\n", folder) @@ -2603,7 +2641,7 @@ func (s *MediaScanner) performInitialScan(ctx context.Context) { job := &Job{ ID: uuid.New().String(), Type: JobTypeDirectoryScan, - Params: map[string]interface{}{ + Params: map[string]any{ "directory": folder, "db": s.db, }, @@ -2626,7 +2664,9 @@ func (s *MediaScanner) Close() error { // Stop watching if s.watcher != nil { - s.watcher.Close() + if err := s.watcher.Close(); err != nil { + fmt.Printf("Warning: failed to close fsnotify watcher: %v\n", err) + } } // Clean up fileStability map to prevent memory leaks @@ -2703,7 +2743,7 @@ func (s *MediaScanner) SyncFilesystemWithDatabase(ctx context.Context) error { } // Build set of existing file paths from filesystem existingPaths := make(map[string]bool) - filepath.WalkDir(folder, func(path string, d os.DirEntry, walkErr error) error { + if err := filepath.WalkDir(folder, func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } @@ -2711,7 +2751,10 @@ func (s *MediaScanner) SyncFilesystemWithDatabase(ctx context.Context) error { existingPaths[s.getRelativePath(path)] = true } return nil - }) + }); err != nil { + fmt.Printf("[POLL-SYNC] Warning: failed to walk directory %s: %v\n", folder, err) + continue + } // Check for orphaned items (in DB but not on filesystem) for _, item := range dbItems { if item.FilePath != "" && !existingPaths[item.FilePath] { @@ -2762,7 +2805,11 @@ func (s *MediaScanner) calculateFileSHA256(filePath string) (string, error) { if err != nil { return "", fmt.Errorf("failed to open file: %v", err) } - defer file.Close() + defer func() { + if err := file.Close(); err != nil { + fmt.Printf("Warning: failed to close file %s: %v\n", filePath, err) + } + }() hasher := sha256.New() if _, err := io.Copy(hasher, file); err != nil { @@ -2805,8 +2852,8 @@ func (s *MediaScanner) extractOPFIdentifiers(epubPath string) (opfIdentifier, op id = strings.TrimSpace(id) // Check for UUID format (urn:uuid:) - if strings.HasPrefix(strings.ToLower(id), "urn:uuid:") { - uuid = strings.TrimPrefix(strings.ToLower(id), "urn:uuid:") + if trimmed, found := strings.CutPrefix(strings.ToLower(id), "urn:uuid:"); found { + uuid = trimmed continue } @@ -2847,8 +2894,8 @@ func (s *MediaScanner) extractISBNFromIdentifier(id string) string { id = strings.TrimSpace(id) // Remove "isbn:" prefix if present - if strings.HasPrefix(strings.ToLower(id), "isbn:") { - id = strings.TrimPrefix(strings.ToLower(id), "isbn:") + if trimmed, found := strings.CutPrefix(strings.ToLower(id), "isbn:"); found { + id = trimmed } // Remove hyphens and spaces