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:
+171
-124
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user