docs: Add package documentation for handlers and services

Add Go package documentation comments to clarify the purpose and scope of:

- internal/handlers/: HTTP request/response handlers for authentication,
  libraries, media items, reading, collections, dashboards, devices,
  analytics, and system features

- internal/services/: Core business logic layer including media scanning,
  library management, search, analytics, and conversion services

These doc comments improve code discoverability and help developers understand
the architectural separation between HTTP handling (handlers) and business
logic (services).
This commit is contained in:
2026-04-13 09:23:12 -04:00
parent f6a5e49965
commit 6398802d15
2 changed files with 174 additions and 124 deletions
+3
View File
@@ -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 (
+171 -124
View File
@@ -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(`<item[^>]*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