Commit Graph
30 Commits
Author SHA1 Message Date
john-okeefe da1732285f fix(scanner): populate page count and total characters during media scanning
The media scanner never populated page_count or total_characters in
media_items, leaving progress display and reading position calculations
with no reliable data. This commit fixes data population for all formats:

Comics (CBZ/CBR/CB7/CBT):
- Add countArchiveImages() helper that walks archive entries and counts
  image files (.jpg, .jpeg, .png, .gif, .webp)
- Call it during comic metadata merge to set metadata.PageCount

PDFs:
- Extract pdfInfo.PageCount from the pdfcpu library (already available
  from PDFInfo call, just never used) and set metadata.PageCount

Reflowable EPUBs:
- Use book.AllChaptersText() to compute metadata.TotalCharacters
- Use book.ChapterCount() to set metadata.ChapterCount

Fixed-layout EPUBs (manga/comics in EPUB format):
- Merge .epub into the .cbz case in countArchiveImages since both are
  ZIP archives with images
- Detect fixed-layout EPUBs via DetectFixedLayoutEPUB() in both the
  Calibre sidecar path (mergeMetadata) and the no-sidecar path
  (extractMetadata), counting images when fixed-layout is detected

Format group on creation:
- Remove the guard condition on UpdateMediaItemFormatGroup so that
  format_group, is_reflowable, and has_fixed_layout are set immediately
  for every new item (not just items with text data)
- Use DetectFixedLayoutEPUB() instead of hardcoding all .epub as
  reflowable, correctly classifying fixed-layout EPUBs

Also pass PageCount to CreateMediaItem and add PageCount,
TotalCharacters, and ChapterCount fields to the MediaMetadata struct.
2026-04-24 14:01:57 -04:00
john-okeefe a4962a87b2 fix: replace invalid new(expression) calls with proper pointer allocation
Go's new() builtin takes a type and allocates a zero value — it cannot
wrap an expression. All instances of new(someExpression) were compile
errors. Replace each with a local variable assignment and address-of
operator.

Affected files:
- handlers/koreader.go: progress field pointers (Chapter, Page, etc.)
- handlers/kobo.go: pagesRemaining pointer
- handlers/queue.go: uuidPtrToString and timestamptzPtrToString helpers
- router/reader.go: bookmark pageNumber and chapterNumber pointers
- services/media_scanner.go: validation error message pointers
- services/worker.go: StartedAt and CompletedAt timestamps
- sync/offline.go: GetDeviceStatus return pointer
- tests/device_test.go: SyncEnabled and SyncFrequencyMinutes pointers
2026-04-23 20:39:50 -04:00
john-okeefe 133ca1fdaa fix(scanner): extract metadata and covers for comic archives and kepub files
Comic archive formats (.cbz, .cbr, .cb7, .cbt) and .kepub files were
falling through to the default case in extractMetadata(), which only
set the title from the filename. This meant ComicInfo.xml was never
parsed and no cover images were extracted for comics without a Calibre
metadata.opf sidecar file.

The fix adds dedicated switch cases:
- .cbz/.cbr/.cb7/.cbt: calls mergeMetadata() with nil, which triggers
  existing ComicInfo.xml parsing (title, series, issue number, writer,
  publisher, genre, reading direction, etc.) and cover image extraction
  from the archive. Falls back to sidecar cover if no image is found.
- .kepub: treated the same as .epub since KEPUB is an EPUB variant,
  enabling full metadata and cover extraction.
2026-04-22 21:18:53 -04:00
john-okeefe e389df92c3 refactor: remove unnecessary type conversions and handle ignored errors across codebase
Remove redundant type conversions that Go 1.26 makes unnecessary or that
were already no-ops:

- uuid.UUID(x.Bytes) → x.Bytes (uuid.UUID is [16]byte, same as pgtype UUID Bytes)
- pgtype.UUID{Bytes: [16]byte(u), Valid: true} → pgtype.UUID{Bytes: u, Valid: true}
- (*time.Time)(&x.Time) → &x.Time
- json.RawMessage(x) → x where x is already []byte
- []byte(stringVal) → stringVal where []byte is expected
- int()/int64()/byte() casts on values already of the target type
- Decompressor(fn) → fn (type is identical)

Handle previously ignored error returns:

- collections.go: check json.Unmarshal error in GetCollection
- conversion_service.go: check fileSize.Scan() error
- app_test.go: check app.Shutdown() error in benchmark
2026-04-21 21:15:59 -04:00
john-okeefe 9ccff320a1 refactor: use errors.Is()/errors.AsType() for error comparison and rename shadowed variables
Replace direct error equality checks (err == pgx.ErrNoRows, err != http.ErrServerClosed)
with the idiomatic errors.Is() function throughout handlers, services, middleware, and app
startup. This correctly handles wrapped error chains.

Also replace a raw type assertion (*HTTPError) with errors.AsType[*HTTPError]() in the
error handler middleware for consistency.

Additionally, rename shadowed variables for clarity:
- sidecar.go: config -> sidecarConfig, systemConfig (shadowed package-level vars)
- media_scanner.go: uuid -> uuidString (shadowed the uuid package import)
2026-04-20 21:20:22 -04:00
john-okeefe 5b2d105609 fix(scanner): always attempt cover extraction for EPUBs and relax manga detection
Two changes to EPUB metadata extraction:

1. Restructure extractMetadata so that fixed-layout detection and cover
   extraction always run for EPUBs, even when extractEPUBMetadata returns
   an error. Previously, a partial failure from the EPUB parser would skip
   cover and format detection entirely, leaving books without covers.

2. Remove the language restriction (ja/jpn) from manga reading direction
   detection. Manga tagged with 'manga' should default to RTL regardless
   of the language metadata, since the tag is an explicit signal from the
   user or metadata source.
2026-04-20 20:45:49 -04:00
john-okeefe 2b139afce6 refactor(services): replace temporary variable pointer pattern with new() builtin
Simplify pointer creation in media scanner validation messages and worker
job timestamps by using inline new() instead of local variable + address-of.

In media_scanner.go this cleans up three validation error message returns
(manga/comics library format checks). In worker.go it simplifies StartedAt
and CompletedAt timestamp assignments.
2026-04-20 08:58:19 -04:00
john-okeefe 6398802d15 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).
2026-04-13 09:23:12 -04:00
john-okeefe f7610c6063 feat(scanner): Add fixed-layout EPUB detection for manga support
- Add DetectFixedLayoutEPUB method to identify manga-style EPUBs
- Check for rendition:layout pre-paginated metadata
- Check for RTL page-progression-direction (manga indicator)
- Check image count threshold (>50 images suggests manga/comic)
- Check subject tags for manga/comic keywords
- Enable proper format detection for manga EPUBs in libraries
2026-04-12 20:43:49 -04:00
john-okeefe 0ff34a683a feat(media_scanner): add genre field to MediaMetadata
Add Genre field to MediaMetadata struct to support genre information
extraction from media files during scanning.
2026-03-30 17:51:08 -04:00
john-okeefe 831668a07d feat(media_scanner): implement smart metadata merging and ComicInfo.xml v2.0 support
Phase 2-3 implementation: Complete ComicInfo.xml parsing with intelligent Calibre merging.

Data Structure Updates:
- ComicInfo struct: Add 19 ComicInfo.xml v2.0 fields (Manga, LanguageISO, Count,
  AlternateSeries, AlternateNumber, AlternateCount, Summary, Imprint, StoryArc,
  SeriesGroup, AgeRating, CommunityRating, MainCharacterOrTeam, Review,
  BlackAndWhite, ScanInformation, Characters, Teams, Locations)
- MediaMetadata struct: Add 14 fields for reading direction and universal/comic metadata

New Functions:
- mergeMetadata(): Smart merging with priority: Calibre metadata.opf → ComicInfo.xml →
  embedded metadata. Extracts reading direction even when metadata.opf exists.
- normalizeMangaType(): Normalize ComicInfo.xml Manga field to database enum
- determineReadingDirection(): Compute reading direction from Manga + language + genre heuristics
- normalizeAgeRating(): Standardize age rating values (Everyone, Teen, Mature, Adult)
- processGenresAndTags(): Universal genre/tag processing for all formats
- extractGenreTagsFromEPUB(): Extract all <dc:subject> values from EPUB
- extractGenreTagsFromComicInfo(): Extract genres from Genre + Tags + Characters + Teams + Locations
- containsTag(): Helper to prevent duplicate tags

Logic Changes:
- extractMetadata(): Now calls mergeMetadata() for smart metadata combination
- processMediaFile(): Updated CreateMediaItem call with all 14 new fields
- Removed duplicate comic metadata extraction (now handled by mergeMetadata)
- CommunityRating uses simple pgtype.Float8 (DOUBLE PRECISION) instead of pgtype.Numeric

This enables complete ComicInfo.xml v2.0 support with 19 fields plus 5 universal fields
that apply to all media formats (ebooks, audiobooks, comics).

Relates to: Phase 2 (data structures), Phase 3 (smart merging), Phase 4 (media item creation)
2026-03-29 21:12:23 -04:00
john-okeefe a900c78faf Add Calibre metadata.opf sidecar file support to media scanner
Implement sidecar-first metadata extraction approach that prioritizes
Calibre metadata.opf files over embedded metadata when available.

Key Features:
- Sidecar-first approach: Check for metadata.opf before extracting embedded
- Full Dublin Core namespace support: Use complete namespace URLs
- Calibre-specific meta tags: Extract series, series_index from <meta> tags
- Graceful degradation: Fall back to embedded metadata on parse failure
- Identifier extraction: Support ISBN and ASIN from Dublin Core identifiers
- Date parsing: Handle ISO 8601 timestamps and simple date formats

Implementation Details:
- Added extractCalibreSidecar() to check for and parse metadata.opf
- Added parseCalibreMetadataOPF() with full Dublin Core namespace handling
- Modified extractMetadata() to try sidecar first, fallback to embedded
- Added CalibreOPFMetadata struct for intermediate parsing
- Cover image support: findSidecarCover() for sidecar metadata

Tests:
- Unit tests for parseCalibreMetadataOPF() with real Calibre file examples
- Integration tests for Calibre library scanning

This allows users with Calibre-managed libraries to import their curated
metadata (series, tags, custom covers) into Bookhoard.

Fixes: #calibre-opf-support
2026-03-26 14:38:20 -04:00
john-okeefe 83b40cb82a fix: replace empty mutex critical section with atomic scan tracking
Removes problematic empty critical section (lines 1993-1994) that
was intentionally waiting for mutex availability. Replaces with
atomic.Bool scan tracking to avoid linter warnings while maintaining
the same scan serialization behavior.

Old pattern:
  mu.Lock()
  // intentionally empty wait for mutex
  mu.Unlock()

New pattern:
  scanRunning atomic.Bool
  if !scanRunning.CompareAndSwap(false, true) {
      return ErrScanInProgress
  }
  defer scanRunning.Store(false)

This provides equivalent functionality with better performance
characteristics and clearer intent.
2026-03-24 16:47:36 -04:00
john-okeefe 821cd3df4c refactor(services): remove debug logging and fix directory scanning
- Remove debug printf statements from media scanner and worker
- Remove unused debug tracking variables (filesSeen, filesProcessed)
- Fix directory walk logic to properly scan the root directory itself
  (previous implementation would skip the root path entirely)

Clean up production code by removing debug artifacts and improving
the directory scanning logic to handle root-level directories correctly.
2026-03-06 10:48:36 -05:00
john-okeefe bb0158e8fb refactor: improve worker type safety and scanner reliability
Worker improvements:
- Add strongly-typed result structs for all job types
- Replace map[string]interface{} with specific result types
- Add JSON tags to JobResult for proper API serialization
- Fix processJob to handle different result types correctly
- Improve directory scan job with proper library folder resolution
- Add debug logging for scan operations

Media scanner improvements:
- Add nil checks for database in GetPollInterval and GetAutoScanEnabled
- Fix pdfcpu API call signature (add validateOnly parameter)
- Add debug logging for scanDirectory with file counters
- Improve error handling and reporting

Test fixes:
- Fix default poll interval expectation from 30s to 60s
- Add settingsCache initialization to scanner tests
- Add folders initialization to ProcessDirtyDirectories test
2026-03-06 01:52:42 -05:00
john-okeefe b3263b2611 feat: add settings cache to reduce database queries in MediaScanner
- Add SettingsCache with TTL-based invalidation (30 seconds)
- Cache scan_poll_interval_seconds and auto_scan_enabled settings
- Reduce database queries from every poll/check to once per TTL period
- Improve error handling with proper fallback values
- Simplify boolean parsing with strings.ToLower for consistency

This optimization reduces database load when checking scan settings,
which occurs frequently during media scanning operations.
2026-03-05 19:35:02 -05:00
john-okeefe 54bfd778db Refactor MediaScanner for improved file watching and job queue integration
- Replace event queue with dirty directories tracking (Jellyfin approach)
- Add file stability checking to wait for file writes to complete
- Add initial scan on startup to detect existing files
- Integrate with Worker job queue for directory scanning
- Change WatchChanges to return error and use atomic.Bool for state
- Add scan_mutex to prevent concurrent scans
- Add Close method with proper cleanup of resources
- Enhance polling with configurable interval
2026-03-05 16:28:40 -05:00
john-okeefe c6fa217092 feat: Add library ID support to media scanner and worker
Add default library ID functionality to improve library targeting
during media scans.

Service changes in internal/services/media_scanner.go:
- Add defaultLibraryID field to MediaScanner struct
- Add SetLibraryID() method to set default library
- Modify processMediaFile() to use defaultLibraryID when set
  - Prioritizes defaultLibraryID over folder-based library detection
  - Provides explicit library targeting for scans

Service changes in internal/services/worker.go:
- Add libraryUUID conversion from string to pgtype.UUID
- Call scanner.SetLibraryID() before ScanFolders()
  - Ensures scanner respects the job's library ID

These changes enable more precise library targeting during media scans,
allowing scans to be directed to specific libraries rather than relying
solely on folder-based detection.
2026-03-01 00:29:39 -05:00
john-okeefe ce72781ec0 refactor(scanner): make poll interval dynamic from database
- Add GetPollInterval() method to MediaScanner to read from database
- Add GetAutoScanEnabled() method to check if auto-scan is enabled
- Remove ScanPollIntervalSeconds from config (now DB-driven)
- Update NewMediaScanner signature to not require interval parameter
- Remove SCAN_POLL_INTERVAL_SECONDS from docker-compose env var
2026-02-28 12:57:06 -05:00
john-okeefe 286d0b5e06 feat(scanner): convert scan poll interval from minutes to seconds
- Rename SCAN_POLL_INTERVAL_MINUTES to SCAN_POLL_INTERVAL_SECONDS in config
- Update MediaScanner to accept interval in seconds instead of minutes
- Adjust default polling interval from 3 minutes to 30 seconds for faster response
- Add debug logging for fsnotify events to aid troubleshooting file watching

This change improves media file detection responsiveness by reducing the
polling interval from minutes to seconds, while maintaining the file
watcher as the primary detection mechanism.
2026-02-28 01:59:35 -05:00
john-okeefe 037e7c1189 feat(scanner): add debounced file watching with polling fallback
- Implement event queue with 3-second debouncing for file system events
- Add configurable polling fallback (default 3 min) via SCAN_POLL_INTERVAL_MINUTES
- Add SyncFilesystemWithDatabase to detect orphaned DB entries and new files
- Integrate utils.ResolveMediaURL for consistent media file path resolution
- Add COOKIE_SECURE env var with SameSite=LaxMode for session cookies
- Update media handler to properly decode URL paths for file serving
- Refactor scanner initialization to accept poll interval configuration
2026-02-28 01:16:27 -05:00
john-okeefe 209e9f2a3c feat: implement relative path storage and URL resolution for media files
- Add libraryService dependency to CollectionHandler and OPDSHandler for centralized path resolution
- Create internal/utils/mediaurl.go with ResolveMediaURL() function as single source of truth
- Update GetMediaItem and ListMediaItems handlers to return resolved URLs in API responses
- Update collection handlers (GetCollection, TestRules, PreviewCollection) to use resolved cover URLs
- Update progress handler (GetAllProgress) to use resolved cover URLs
- Add library_id to GetCollectionItems SQL query to enable URL resolution
- Refactor media scanner to store relative paths instead of absolute filesystem paths
- Add ResolveMediaPath() to LibraryService for resolving relative paths to absolute paths
- Add ServeFile endpoint at /uploads/library-:id/* for authenticated file serving
- Add MimeTypes map to library_service.go for consistent MIME type handling
- Update DownloadBook handler to use resolved filesystem paths
- Add getRelativePath() helper to MediaScanner for converting absolute to relative paths
- Use strings.EqualFold for case-insensitive path comparisons in zip extraction

This change enables the application to work with relative paths stored in the
database, making it portable across different server environments while
maintaining backward compatibility with existing absolute paths.
2026-02-27 16:51:44 -05:00
john-okeefe d802236874 scanner: fix library isolation, file mtime, force rescan, and deletion handling
Fix 1 - File modification time for created_at:
- Get file.ModTime() in processMediaFile and pass to CreateMediaItem
- Modified SQL INSERT to include created_at column

Fix 2 - Force rescan UPDATE instead of DELETE+INSERT:
- Changed force rescan logic to call updateMediaItem instead of delete + create
- Preserves created_at timestamp on force rescan

Fix 3 - GetMediaItemByFilePath filters by library_id:
- Added library_id to WHERE clause in SQL query
- Created GetMediaItemByFilePathAnyLibrary for cross-library lookups (KOReader)
- Added SetLibraryID method to MediaScanner
- Updated handler to call SetLibraryID for watch mode

Fix 4 - File deletion handling with persistent logging:
- Added fsnotify.Remove handler in WatchChanges
- Added orphan cleanup in ScanFolders after scan completes
- Created scanner_logger.go with daily log rotation (7 days)
- Logs to /app/logs/scanner-deletes-YYYY-MM-DD.log and scanner-errors-YYYY-MM-DD.log
- Individual deletes with enhanced safety logging

Note: Integration tests can now safely scan /app/uploads because
GetMediaItemByFilePath now filters by library_id, preventing
cross-library interference.
2026-02-26 16:39:42 -05:00
john-okeefe a97220e654 backend: add force rescan parameter to scanner
- Add Force bool field to ScanLibraryRequest in handlers
- Pass force param through job params to worker
- Add forceRescan field and SetForce method to MediaScanner
- Modify processMediaFile to delete and re-create existing items when force=true
- Default behavior unchanged (force=false maintains skip-if-exists)
2026-02-26 10:11:46 -05:00
john-okeefe 213e7b9a9d Add EPUB and PDF cover extraction with metadata support
- Add extractEPUBCover() to extract embedded covers from EPUB files
  - Parse OPF manifest for cover-image properties
  - Support meta name="cover" tags
  - Fall back to common cover paths (cover.jpg, images/cover.jpg)

- Add findSidecarCover() for sidecar cover detection
  - Check cover.jpg, cover.png, cover.webp
  - Check folder.jpg, folder.png
  - Check {basename}.jpg (same name as media file)

- Add extractPDFCover() to extract first page images from PDFs
  - Use pdfcpu API to extract images from page 1
  - Save largest image as cover

- Update extractPDFMetadata() to use pdfcpu API
  - Extract Title, Author, Subject, Creator, Producer
  - Call extractPDFCover for embedded covers
  - Fall back to sidecar covers

- Update extractMetadata() for EPUB to call extractEPUBCover
  - Try embedded cover first, then sidecar
2026-02-25 20:53:18 -05:00
john-okeefe 0295bf7a37 Implement backend scan progress tracking (Steps 1-7)
Implements comprehensive progress tracking for scan jobs to provide real-time
statistics to the frontend (files_scanned, new_items, errors).

Changes:
1. Extended JobResult struct with new fields:
   - FilesScanned: total files processed
   - NewItems: books added to database
   - Errors: scan errors encountered

2. Added progress callback mechanism:
   - Job.ProgressCallback function field for real-time updates
   - Job.UpdateProgress() method to trigger callbacks
   - Worker stores callback and updates JobResult during scan

3. MediaScanner now tracks statistics:
   - totalFiles, newItems, errors counters
   - GetStats() method to retrieve statistics
   - First pass counts total files for progress calculation
   - Batches progress updates every 10 files (reduces mutex contention)
   - Final update ensures 100% progress is reported

4. Updated processMediaFile signature:
   - Returns (bool, error) instead of (error)
   - true = new item created, false = existing/updated/error
   - Increments newItems counter when creating database entries
   - Updated WatchChanges to handle new return value

5. Worker job completion extracts stats:
   - Parses result map for files_scanned, new_items, errors
   - Stores in final JobResult for API response

6. GetScanStatus API response includes new fields:
   - files_scanned, new_items, errors now in JSON response
   - Frontend can display real-time progress

Design decisions:
- Batching every 10 files balances performance vs. granularity
- Thread-safe via worker mutex (w.mu.Lock/Unlock)
- Callback pattern decouples scanner from job management
- processMediaFile return type allows tracking new vs. updated items
- Maintains backward compatibility (uses || 0 fallbacks in frontend)

Testing:
- All code compiles successfully
- Follows service layer pattern (no business logic in handlers)
- No database schema changes
- Integration tests to be added in Step 8 (separate commit)

Files modified:
- internal/services/worker.go (JobResult, Job struct, processScanJob, processJob)
- internal/services/media_scanner.go (struct fields, GetStats, ScanFolders, processMediaFile)
- internal/handlers/scanner.go (GetScanStatus response)

Related: TASKS-backend-progress-tracking.md Steps 1-7
2026-02-25 10:52:53 -05:00
john-okeefe 807d7b36ef refactor: update sevenzip import to use vendored package
Updated import path from github.com/bodgit/sevenzip to
bookhoard/internal/sevenzip to use the vendored package.
2026-02-22 16:29:28 -05:00
john-okeefe 2706ae52c1 refactor: remove Phase X terminology from source code comments
Remove planning document phase references from code comments:

app_test.go:
- Remove Phase 5 references from 8 test function comments

querier.go & queries.sql.go:
- Remove Phase 1, 2, 3, 4, 6 references from section headers
- Clean up week numbers (Weeks 5-6, Week 3-4, etc.)

queries.sql:
- Remove Phase 4 references from Kobo queries

kobo.go:
- Remove Phase 6 references from ContentId mapping comments

progress.go:
- Remove Phase 1 reference from route comment

media_scanner.go & media_scanner_library_type_test.go:
- Remove Phase 2 references from library type scanning comments

schema.sql:
- Remove Phase 1, 2, 3, 4, 5, 7 references from table/section comments
- Clean up: Format Detection, Progress Tracking, Device Registry,
  Sync Queue, Conflict Resolution, Reading History, Indexes, etc.

test_helpers.go:
- Remove Phase 6 reference from handler setup comment

These phase numbers were from internal planning documents and have no
meaning in the codebase. Removing them makes the code self-documenting.
2026-02-13 21:50:29 -05:00
john-okeefe 0f8db2ab07 Add ISBN-10 to ISBN-13 validation and conversion
Enhance NormalizeISBN to validate and convert ISBNs:
- Validate length (10 or 13 digits), return error if invalid
- Convert ISBN-10 to ISBN-13 by prefixing '978' and recalculating checksum
- Add NormalizeISBNSafe for backward compatibility in scanners

This ensures all ISBNs stored in database are valid ISBN-13 format.
2026-02-11 09:42:18 -05:00
john-okeefe 0e813f14ce refactor(services): rename EbookScanner to MediaScanner
- Rename EbookScanner struct to MediaScanner
- Rename EbookMetadata struct to MediaMetadata
- Rename NewEbookScanner to NewMediaScanner
- Rename processEbookFile to processMediaFile
- Rename updateEbook to updateMediaItem
- Rename getEbookByFilePath to getMediaItemByFilePath
- Remove unused isEbookFile method
- Update all method receivers
- Update variable names (ebookFiles -> mediaFiles, existingEbook -> existingItem)
- Update print statements to use 'media' terminology
- Update worker.go to use NewMediaScanner
- File renamed: ebook_scanner.go -> media_scanner.go
2026-02-08 14:29:49 -05:00