114 Commits
Author SHA1 Message Date
john-okeefe 03cb4c7869 feat(admin): startup hash backfill and hash-conflict resolution API
Release / build-and-push (push) Successful in 2m48s
Complete the SHA-256 lifecycle for preexisting databases: items
imported before hashing existed get hashed automatically, and any
content duplicates discovered in the process land on the new admin
Hash Conflicts page for an explicit keep/merge decision.

HashBackfillService (runs once 30s after startup, independent of
auto-scan):
- hashes every media_items row where file_sha256 IS NULL, resolving
  each path through LibraryService; per-item failures are logged and
  skipped so one unreadable file cannot block the pass
- no-op once everything is hashed (logged and skipped)
- finishes with a conflict sweep flagging every content-duplicate
  group via FindHashConflictGroups + CreateHashConflict; the sweep
  runs after the per-item pass because a preexisting pair only
  becomes detectable once both sides have their hash

API (admin-only):
- GET /api/admin/hash-conflicts - pending groups with member items
  and usage counts
- POST /api/admin/hash-conflicts/:id/resolve - action=keep_all, or
  action=keep with keep_uuid: validates the uuid belongs to the
  group, re-parents every other copy's child rows onto the kept item
  (reparent_media_item_children), deletes the losers, and records
  the resolution + resolving admin; accepts form or JSON bodies and
  returns the htmx resolved fragment

Page route /admin/hash-conflicts (admin-only) renders the template
with hydrated conflict data; HashConflictsHandler wired into the
router Config and constructed in main.

Verified end-to-end against the live database: duplicate detection,
pending listing, keep_all resolution, merge path (re-parent +
delete), and - critically - a resolved group is not re-flagged by a
later sweep (upsert no-op). Database restored afterward.
2026-08-14 08:53:01 -04:00
john-okeefe 77990d0dc0 feat(scanner): recompute hashes on rescan and flag content duplicates
Force rescan was metadata-only: updateMediaItem never touched the
hash identifiers, so a force scan could not backfill file_sha256 for
items imported before hashing existed (or where extraction originally
failed). Those items were invisible to content dedup and SHA-256
device matching with no way to fix short of delete + re-import.

processMediaFile now refreshes hash identifiers in three cases:
- force rescan (the admin Scan button becomes the backfill tool)
- file size change (stored hash is stale - the bytes changed)
- unchanged file with no stored hash (ordinary scans self-heal the
  legacy backlog incrementally, no admin action required)

Each recompute runs recordHashConflictIfAny: when the freshly stored
hash is now shared by more than one item in the library, the group is
upserted into hash_conflicts for the admin Hash Conflicts page. The
upsert is a no-op for already-tracked groups, so resolved 'keep both'
decisions stick.

Also extract a package-level computeFileSHA256 (the scanner method
now delegates to it) so the startup backfill service can hash files
without a scanner instance.
2026-08-14 08:52:18 -04:00
john-okeefe 60a94df8e1 feat(sync): add shared BookResolver with format-aware SHA-256 matching
The platform had three duplicated, divergent book resolvers (koreader,
kobo, BookMatchingService) and none of them consulted
media_item_formats.file_sha256 - per-format hashes for converted files
(KEPUB, PDF) are computed and stored at import/conversion time but were
never used for lookup. GetMediaItemFormatBySHA256 existed with zero
callers. Any client holding a converted file could never match by
hash.

Add internal/services/book_resolver.go: a single shared resolution
path from client-supplied identifier to media_item.
ResolveBySHA256 checks media_items.file_sha256 first (indexed
GetMediaItemBySHA256), then falls back to media_item_formats.
file_sha256 (indexed GetMediaItemFormatBySHA256, first caller) so a
converted format matches with equal confidence. The import-time
SHA-256 is the canonical identifier shared by every interface.

Wire two of the existing resolvers through it:

- BookMatchingService.matchBySHA256: replaces the in-memory
  ListMediaItems scan of up to 1000 rows with the resolver's indexed
  lookups, and gains format awareness for the link/auto-link UI.
  MatchMethod now reports sha256_sha256 or sha256_sha256_format
- KoboHandler.mapContentIdToBookhoardUUID: the SHA-256 heuristic
  branch (ContentId that looks like a 64-char hash) now resolves
  format-aware too. Kobo's entitlement_id wire identity is untouched;
  only the opportunistic hash branch changed
2026-08-14 08:25:23 -04:00
john-okeefe 9b171a0060 fix(scanner): prevent duplicate media item imports
A read-then-write race in processMediaFile allowed the same file to be
imported twice: two concurrent scan jobs (startup scan, fsnotify dirty-
directory scan, periodic backup poll, or a manual scan each run on
separate worker goroutines with separate MediaScanner instances) could
both SELECT 'not found' and both INSERT. There was no transaction, no
row lock, no unique constraint on (library_id, file_path), and no
ON CONFLICT clause, so nothing stopped the double insert. Observed in
production as two identical 'Head First SQL' rows created in the same
second (same sha256, size, path, library).

Database enforcement:
- schema.sql: add UNIQUE(library_id, file_path) constraint, guarded so
  re-runs don't error
- schema.sql: add self-healing migration that runs on every startup -
  dedup_media_items_by_path() collapses existing path-duplicates and
  reparent_media_item_children() moves all child rows (progress,
  highlights, bookmarks, notes, collections, formats, aliases, kobo
  entitlements, etc.) onto a survivor before deleting losers, so the
  constraint applies cleanly on already-duplicated servers without
  losing reading history. Survivor picks the row with the most user
  data, ties broken by lowest id
- CreateMediaItem: upsert via ON CONFLICT (library_id, file_path) DO
  UPDATE so concurrent inserts collapse to one row and return it
- CreateMediaItemFormat: upsert via ON CONFLICT (media_item_id,
  format_type), closing the same race on format rows

Application-level guards:
- media_scanner processMediaFile: after computing the file hash, check
  GetMediaItemBySHA256AndLibrary (new query) and treat the file as
  existing when identical content is already in the library under a
  different path (content dedup, library-scoped so multi-library
  setups still work)

Ops tooling:
- scripts/dedup_media_items.sql: standalone idempotent maintenance
  script with a dry-run report (path + content duplicate groups, child
  row counts) and transactional cleanup, for servers that prefer to
  dedup manually before upgrading

Verified against the live database: the duplicate pair was collapsed
(reading_progress preserved on the survivor), schema.sql re-runs are a
no-op, and the constraint is in place with 62 unique books remaining.
2026-08-14 08:18:36 -04:00
john-okeefe f5d9578375 feat(reader): wire double-page spread setting into web reader
The double_page_spread checkbox in the reader settings panel was inert:
it had no Alpine binding, no apply logic, and no persistence. Default
was also inconsistent (false in settings-manager, absent from server
defaults).

- Add doublePageSpread state to the reader Alpine component, loaded
  from saved settings (default true)
- Add applyDoublePageSpread() which sets the renderer's 'spread'
  attribute to auto/none and persists the setting via saveSettings
- Apply the spread attribute during fixed-layout renderer init
- Bind the settings checkbox with x-model and @change
- Add double_page_spread: true to ReaderService server defaults so
  new users get the same starting value the client expects
- Also improve the PDF pan/select toolbar button: distinct smart-
  select vs pan icons, highlighted state while pan mode is active,
  and dynamic tooltips/aria-labels explaining each mode
2026-08-14 08:17:25 -04:00
john-okeefe 936a48405b refactor(background): parameterize sync queue and worker pool constructors
Split each constructor into a default-args wrapper and a config-accepting
variant so the sync queue interval/batch size and the worker pool size/
queue cap can be sourced from the settings registry at startup. These
values are constructed once at boot, so they are tagged requires_restart
in the admin UI.

queue.go:
- NewSyncQueueProcessorWithConfig(db, interval, batchSize) takes the
  flush interval and batch size as parameters; NewSyncQueueProcessor
  becomes a thin wrapper with the historical 5s / 50 defaults.

worker.go:
- NewWorkerWithConfig(numWorkers, queueCap, connManager) takes the
  queue capacity as a parameter; NewWorker becomes a thin wrapper with
  the historical cap of 100.

No behavior change for existing callers; main.go will switch to the
config-accepting variants in a follow-up wiring commit.
2026-08-10 08:02:02 -04:00
john-okeefe d12911d3c8 feat(api): make device rate limits, OPDS page size, and conversion cache configurable
Move three more hardcoded values behind the settings registry. All
apply immediately on the next request (no restart needed).

device_auth.go:
- DeviceAuthMiddleware reads per-route device rate limits (sync /
  progress / metadata per minute) from the registry on each
  authenticated request via a rateLimitConfig() helper, falling back to
  the Default* constants when no registry is wired.
- The X-RateLimit-Limit response header previously hardcoded "60" for
  every request type; it now reflects the actual configured limit for
  the request type via rateLimitForRequestType().

opds.go:
- Default (50) and maximum (200) OPDS page sizes come from the
  registry's OpdsDefaultPageSize()/OpdsMaxPageSize() instead of inline
  literals, so catalog pagination can be tuned without a redeploy.

conversion_service.go:
- The 24h kepub cache lifetime is read from the registry via a
  cacheTTL() helper (was a bare 24 * time.Hour literal in the
  constructor). The field default is retained for tests that construct
  the service directly.
- conversion_service_test.go updated to assert both the field default
  and the cacheTTL() accessor return 24h.
2026-08-10 08:01:28 -04:00
john-okeefe 59d5de3607 fix(scanner): eliminate fsnotify watcher leak and harden worker against panics
The bookhoard container crashed with 'panic: Failed to create file
watcher: too many open files' (media_scanner.go) after running for a few
hours, preceded by floods of 'no space left on device' from watcher.Add.

Root cause: every scan job called NewMediaScanner(), which eagerly
created an fsnotify watcher. SetFolders() then walked the entire library
tree and registered one inotify watch per directory (~3,000+ across the
libraries), and ScanFolders() registered them again during its walk. The
worker never called scanner.Close() on these ephemeral per-job scanners,
and the worker loop had no recover(), so:

  1. Leaked watchers accumulated until the kernel inotify watch cap was
     hit (ENOSPC -> 'no space left on device'), then
  2. the process fd limit (ulimit -n 1024) was exhausted, causing
     fsnotify.NewWatcher() to fail with EMFILE, and
  3. NewMediaScanner panicked on that error, taking down the whole
     process (exit code 2). With no restart policy the container stayed
     down.

The scan jobs run frequently (scan_poll_interval), so the leak built up
within hours. Note this was NOT a disk-space issue; df showed plenty free.

Fix:

- media_scanner.go: NewMediaScanner no longer creates a watcher eagerly
  (s.watcher starts nil), which removes the panic site entirely -- there
  is nothing to fail at construction. The watcher is created lazily only
  when needed.

- media_scanner.go: SetFolders gains a [?1049h(B[?7h[?25lEvery 2.0s: boolgaruda-ser8: Fri 31 Jul 2026 10:45:41 AM EDTin 0.002s (127)
sh: line 1: bool: command not found
[?12l[?25h[?1049l
[?1l> parameter. It creates
  and populates a watcher (returning an error instead of panicking) only
  when watch=true; otherwise it skips all watcher.Add calls. ScanFolders
  guards its watcher.Add with a nil check, and the WatchChanges event
  loop exits cleanly when there is no watcher (polling still runs).

- worker.go: the worker() loop now wraps each job in defer/recover() so a
  panicking job is recorded as failed and can never kill the process.

- worker.go: the three ephemeral scan handlers (processScanJob,
  processSetFoldersJob, processDirectoryScanJob) now defer scanner.Close()
  and call SetFolders(..., false), so scan jobs allocate zero watchers and
  zero inotify watches. Any pre-existing leak is also bounded by Close().

- handlers/scanner.go: the long-lived watch-mode scanners (StartScanner
  and StartWatchModeForLibrary) pass watch=true since they actually read
  watcher.Events for live change detection.

- calibre_integration_test.go: updated to the new SetFolders signature
  (watch=false, matching one-off scan usage).

Auto-add is fully preserved: new files are still detected by the periodic
poller (startBackupScan), which is independent of fsnotify and unaffected
by these changes. The watch-mode event loop remains as bonus responsiveness
when inotify is available; through Docker bind mounts where inotify is
unreliable, polling is what catches new books.
2026-07-31 10:45:41 -04:00
john-okeefe 6a352a6afb refactor(services): accept optional libraryID for All Libraries support
- dashboard_service.go: Change libraryID parameter from uuid.UUID to
  pgtype.UUID across GetDashboardSections, GetDashboardPreferences,
  and all helper methods. pgtype.UUID{Valid: false} now signals
  "no library filter" (All Libraries), which gets passed through
  to sqlc.narg() in the SQL layer.

- series_service.go: Drop libraryID parameter from GetSeriesBooks
  entirely. Series are not library-specific — all books in a series
  are shown regardless of which library they belong to.
2026-05-18 17:52:24 -04:00
john-okeefe 5caebcfe45 feat(worker): broadcast scan_complete WebSocket message on scan job finish
The MessageTypeScanComplete constant existed but was never actually sent by
the worker. This meant the frontend had no way to know when a scan finished.

- After a JobTypeScan completes, broadcast scan_complete to the job's user
  via WebSocket ConnectionManager
- Includes job_id, files_scanned, new_items, and errors in the payload
- Only broadcasts for JobTypeScan (not other job types) when connManager
  is available and job.UserID is set
2026-05-16 19:31:02 -04:00
john-okeefe b8dc4e87d5 fix(library): sync allowed extensions from Go source of truth to DB on startup
AllowedExtensions in Go was the intended single source of truth for library
type file extensions, but it was never synced to the database. This caused
missing extensions like .pdf for manga to be absent from library_types.

- Add SyncAllowedExtensions() to sync Go AllowedExtensions map to DB
- Call SyncAllowedExtensions() from cmd/server/main.go on startup
- Ensure .pdf is included in manga extensions
2026-05-16 19:30:46 -04:00
john-okeefe 0855dd7b3b fix(scanner): replace mtime polling with recursive fsnotify watching
The root cause of scanner failures in Podman containers was NOT that
inotify doesn't work through bind mounts (it does — same kernel, same
inodes). The real bug was SetFolders() only watching root directories.
Linux has no recursive inotify — every subdirectory must be added
individually to the watcher.

Changes:
- SetFolders() now walks all subdirectories and adds each to the watcher
  (same approach as Audiobookshelf/Kavita)
- Remove broken mtime-based detection: seedDirectoryMtimes,
  pollDirectoryChanges, detectChangedRoots, checkDirectoryMtimes,
  SyncFilesystemWithDatabase — all unreliable in container overlay mounts
- Replace StartPolling with startBackupScan: enqueues full JobTypeScan
  every 5 minutes (down from 30) as a safety-net fallback
- enqueueLibraryScan() sets job.UserID from admin ID so the worker can
  broadcast WebSocket messages
- performInitialScan() sets job.UserID for the same reason
- Add [WATCHER] prefix logging to all fsnotify event loop messages
- Add defense-in-depth: fallback to GetFirstAdmin() when library has
  no created_by_admin_id (NULL from test cleanup)
- Fix processDirectoryScanJob to use prefix-match (GetLibraryByFolderPathPrefix)
- Fix nil context panic: all jobs now set Context: context.Background()
- Remove mtime-related tests; update default interval test from 30m to 5m
2026-05-16 19:30:39 -04:00
john-okeefe ced90cd1f4 feat(scanner): add directory mtime-based fast polling for container environments
Podman rootless containers with overlay storage do not propagate inotify
events through bind mounts, making the fsnotify file watcher ineffective.
This caused new files added on the host to go undetected until the
5-minute full-filesystem-walk polling fallback caught them.

Add a lightweight directory mtime polling mechanism that runs every 10
seconds, checking stat() on all subdirectories under watched library
folders against a cached mtime value. When a directory's mtime changes
(indicating files were added/removed/renamed), it feeds into the existing
markDirectoryDirty() → processDirtyDirectories() → job queue pipeline.

Changes:
- Add dirMtimes cache + mutex to MediaScanner struct
- Add seedDirectoryMtimes() to populate cache on startup (prevents
  false-positive flood on first poll)
- Add pollDirectoryChanges() goroutine (10s ticker) and
  checkDirectoryMtimes() (walks directories, compares mtimes)
- Launch mtime poller from WatchChanges() alongside existing goroutines
- Rename StartPolling logs to [ORPHAN-CLEANUP] to clarify its role
- Change default poll interval from 60s → 30m (new file detection now
  handled by the fast mtime poll; full sync focuses on orphan cleanup)
- Update GetScanSettings default from 60 → 1800 seconds
- Add 5 tests: seed cache, skip nonexistent, detect new dir, skip
  unchanged, detect modified dir

Expected result: new files detected in ~20 seconds (10s poll + 10s
debounce) regardless of inotify/container support.
2026-05-12 16:54:35 -04:00
john-okeefe dc07a2f19f fix(search): add json tags to FieldValue struct for correct API response
FieldValue struct had no json tags, so Go marshaled fields as uppercase
(Value, Count, Score) but frontend expected lowercase (value, count).
This caused all autocomplete dropdowns (tags, author, series, language)
to silently fail — tagSuggestions[].value was undefined, crashing
toLowerCase() calls and producing empty dropdowns.
2026-05-10 16:12:10 -04:00
john-okeefe f199918775 fix(scanner): wire all metadata fields in updateMediaItem and skip image dupes
updateMediaItem (used by force rescan) was missing 22 fields including
Language, Genre, PageCount, CopyrightYear, GoodreadsID, and all 14 new
columns from the SQL query fix. Now wires all 37 UpdateMediaItemParams.

Also adds hasSiblingBookFile() early exit in processMediaFile: if a file
is an image (jpg/png/webp/etc) and its directory contains an actual book
file (epub/pdf/cbz/etc), skip importing the image as a standalone media
item. This prevents cover images and interior art from appearing as
duplicate library entries.
2026-05-10 11:51:43 -04:00
john-okeefe cce7ad4907 test(series): add unit and integration tests for series feature
Unit tests:
- series_service_test.go: test continueSeriesRowToMediaItems conversion
  with valid fields, null fields, and comprehensive field mapping
- series_test.go: test handler initialization and textToString helper

Integration tests (series_integration_test.go):
- GET /api/series: requires library_id, rejects invalid UUID, returns
  empty array for empty library, pagination params, limit clamped to
  100, response structure validation, special characters in names
- GET /api/series/books: requires library_id and name, handles
  nonexistent series, unauthorized access
- Restore Continue Series system collection
- Dashboard sections include all 5 collections (including continue-series)

Update test helpers:
- Add SeriesHandler to setupTestServer router config
- Add 5th Continue Series collection to createDefaultCollectionsForUser
- Update dashboard integration test for 5 collections
2026-05-08 20:27:40 -04:00
john-okeefe 9405afa2c5 feat(series): add SeriesService and wire continue-series into dashboard
Create SeriesService with methods for paginated series listing, cover
path resolution, series book listing, and a conversion helper for
GetContinueSeriesItemsRow to MediaItems.

Wire the continue-series query type into DashboardService's
getCollectionItemsByQueryType switch and add its metadata to the
RestoreSystemCollection default collection map.
2026-05-08 20:26:47 -04:00
john-okeefe ff0517d038 fix(library): sync allowed extensions across service, schema, and tests
Add .epub and .pdf to comics type, add .pdf to manga type, and ensure
avif/tiff/tif extensions are consistently included in manga across all
layers. Update test fixtures to match the canonical extension lists.
2026-05-01 14:31:17 -04:00
john-okeefe d3ddecb840 fix(reader): persist chapter metadata cache to database
The DetectChapters function in reader.go was serializing chapter detection
results to JSON but then discarding the bytes with `_ = metadataBytes`
instead of writing them to the database. This meant chapter_metadata in
media_items was never populated, forcing re-detection on every request.

Replace the no-op discard with an actual UpdateMediaItemChapterMetadata()
call using the existing sqlc-generated query.
2026-04-24 14:02:13 -04:00
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 8baecad379 fix(tests): use errors.Is() for error comparison and improve resource cleanup in analytics tests
Replace direct error equality check with errors.Is() in media_scanner_hash_test.

In analytics_test.go, improve defer patterns by capturing resp.Body as a named parameter
to avoid stale references, and add require.NoError() checks on all json.NewDecoder().Decode()
calls that were previously silently ignoring decode errors.
2026-04-20 21:20:38 -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 e1aef8e85f refactor(services): Modernize Go code style in collection and filters services
Apply Go 1.18+ language features and modern style:

internal/services/collection_service.go:
- Use map[string]any instead of map[string]interface{} (Go 1.18+)
- Use range clause with single variable for iteration-only loops
- Replace if-else chains with switch statements for better readability
- Remove explicit type initialization for zero values

internal/services/filters.go:
- Add Err prefix to custom error variable for error naming convention

internal/router/library.go:
- Use cfg.ProcessingIssuesHandler instead of local processingIssuesHandler variable
- Ensures proper dependency injection through router config

These changes follow current Go best practices and improve code readability.
2026-04-13 09:25:01 -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 83ba24e31a Expose library_type_name in API and remove redundant empty fields
- Add library_type_name to GetMediaItem handler response in media.go
- Remove empty LibraryName and LibraryTypeName fields from:
  - ListMediaItemsRow in collections.go handler
  - ListMediaItemsRow in dashboard_service.go
- These fields are now populated at the database level via trigger

The library_type_name is now automatically populated in the database
when a media item is created, so we remove the manual empty string
assignments and expose the actual value in the API response.
2026-04-04 22:49:12 -04:00
john-okeefe d91e5fac3d fix: update reader service and handler
- Add panel_layout to getDefaultSettings() for dockable panels
- Remove template rendering from ShowReader (router handles SSR)
- Fix pgtype.Int4 marshaling to JSON (no explicit int conversion)
- Remove unused strings import from handlers
2026-04-03 17:20:23 -04:00
john-okeefe 74575d9a86 feat: add reader service, handler, and router 2026-04-03 16:53:37 -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 fd74415a4a test: add unit tests for comic metadata processing
Phase 6.1 implementation: Unit tests for metadata helper functions.

Test Coverage:
- TestNormalizeMangaType: Verify Manga field normalization to database enum values
  (unknown, no, yes, yes_and_right_to_left)
- TestDetermineReadingDirection: Test reading direction computation heuristics
  (explicit Manga field, Japanese language, webtoon/manhwa genre tags, Western default)
- TestNormalizeAgeRating: Verify age rating standardization
  (Everyone, Teen, Mature, Adult with various input formats)

These tests ensure the helper functions correctly normalize ComicInfo.xml data
before storage in the database.

Relates to: Phase 6.1 unit testing
2026-03-29 21:12:24 -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 765123a545 Update default system collection names to Title Case format
Changed the 4 default system collection names from kebab-case to Title Case
with spaces for better readability and professional appearance:

Changes:
- "continue-reading" → "Continue Reading"
- "recently-added" → "Recently Added"
- "recently-read" → "Recently Read"
- "not-started" → "Not Started"

Implementation details:
- Collection Name field: Updated to Title Case (user-visible identifier)
- QueryType field: Unchanged, remains kebab-case (internal switch/case logic)
- All map keys updated to use new Title Case names as lookups
- Restore modal option values updated to match new names

Files modified:
- internal/handlers/auth.go: Default collection creation for new users
- internal/handlers/dashboard.go: Restore endpoint validation map
- internal/services/dashboard_service.go: System collection metadata map
- templates/restore_system_collection_modal.templ: Form option values

Benefits:
- Cleaner, more professional display names for end users
- Consistent with existing restore modal UI labels
- Improved user experience with properly formatted collection names
- Internal QueryType identifiers remain unchanged for code logic
2026-03-28 21:21:03 -04:00
john-okeefe ba243c223d fix: implement proper 3-state boolean handling in backend
Updated the backend services and handlers to properly detect and pass
the has_cover parameter's validity state to the database layer.

Changes:
- services/search.go: Changed HasCover type from bool to pgtype.Bool
  to support 3-state logic (NULL, TRUE, FALSE)
- handlers/media.go: Fixed 3-state detection by checking if has_cover
  exists in query params before setting Valid flag
- router/search.go: Fixed 3-state detection to match media.go logic
- router/frontend.go: Use pgtype.Bool{Valid: false} for SSR initial
  load to ensure no filtering occurs on first page load

The key fix is detecting whether the has_cover parameter was actually
sent in the request:
- Parameter not sent → pgtype.Bool{Bool: false, Valid: false}
- Parameter sent as "true" → pgtype.Bool{Bool: true, Valid: true}
- Parameter sent as "false" → pgtype.Bool{Bool: false, Valid: true}

Previously, media.go was hardcoding Valid: true, which meant it was
always filtering by has_cover=false (only books without covers) when
the parameter wasn't sent, causing searches to incorrectly return
0 results for queries like "1984".

This ensures consistency between the JSON API endpoint (media.go) and
the HTML endpoint (search.go), and fixes the critical bug where SSR
was returning 0 books on initial page load.
2026-03-27 18:07:51 -04:00
john-okeefe a33d521492 feat(search): add ExecuteSearch method to SearchService
Add shared search method that returns results with count. This method will be used by both JSON API endpoints and HTML rendering for HTMX, avoiding duplicate business logic.

- Extracts common search logic into reusable service method
- Returns search results with total count for pagination
- Follows DRY principle by eliminating duplicated search code
2026-03-27 14:50:21 -04:00
john-okeefe be31cc88f1 feat: enhance search with date-prioritized year filtering and true exact matching
Improve media item search functionality with two key enhancements:

1. Date-prioritized year filtering:
   - Prioritize date_published over copyright_year for year range queries
   - Fall back to copyright_year when date_published is NULL
   - Extract year from date_published timestamp for comparison

2. True exact search matching:
   - Replace ILIKE pattern matching with exact equality for quoted queries
   - Use search_query directly instead of wildcard pattern for exact matches
   - Remove SearchPattern parameter and related wildcard logic
   - Add COALESCE handling for author/series NULL values in exact matches

These changes make year filtering more accurate with published dates
and provide genuine exact matching when users wrap queries in quotes.

Refs internal/database/queries/queries.sql:475, internal/services/search.go:62
2026-03-26 15:35:31 -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 bb8b4f9f63 feat: implement tags filter in service layer
- Add TagsFilter string to SearchParams struct
- Update dbParams building to include tags_filter
- Add tags case to SearchFieldValues service for autocomplete
- Handle SearchTagsValues query results

Enables the backend service layer to process tag filtering requests
and provide autocomplete suggestions for tag values.

Relates to IMPLEMENTATION_TAGS_FILTER.md Phase 2
2026-03-25 20:38:11 -04:00
john-okeefe a643bd43b8 refactor: clarify database parameter validation in search service
Add comment to document that filter parameters use pgtype.Text
with explicit Valid=true flag to ensure proper SQL parameter handling.
This clarifies the intent behind the parameter building logic.

Improves code documentation for future maintenance.
2026-03-25 18:03:02 -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 bd3057ec80 fix: correctly handle NULL library_id in search service
Updates SearchMediaItemsUnified to conditionally set LibraryID parameter
only when it's valid. Previously, the code always set LibraryID in the
dbParams struct, which caused pgx to pass a zero UUID instead of NULL
to PostgreSQL.

New behavior:
  - Only sets dbParams.LibraryID when params.LibraryID.Valid is true
  - When library_id is empty, LibraryID is omitted from the struct
  - Go's zero value + pgx's "field not set" detection = NULL in SQL

Also fixes type mismatches in SearchFieldValues method where
SearchQuery parameter needed explicit pgtype.Text wrapping with
Valid=true flag for proper nullable text handling.

This ensures that omitting the library_id query parameter results in
searching across all libraries, not filtering by zero UUID.
2026-03-24 16:47:30 -04:00
john-okeefe 871d5eafe6 feat: add SearchService for unified search functionality
- Create SearchService with SearchMediaItemsUnified method
- Add SearchFieldValues method for autocomplete dropdown population
- Add parseSearchQuery helper for quote detection (exact vs fuzzy search)
- Move all business logic from handler to service layer
- Follow established service pattern (FiltersService, CollectionService)
- Service created inside handler constructor, not in main.go
- SearchParams struct supports all filter types + sort parameter
- FieldSearchParams struct for field-specific autocomplete queries
- Returns FieldValue results with count and similarity scores

This provides a clean service layer abstraction for search operations.
2026-03-23 22:37:40 -04:00
john-okeefe 9ab2796902 feat: implement SearchService with unified search logic
Create new SearchService to encapsulate all search business logic:

Features:
- Unified search combining text search with filters
- Fuzzy matching using pg_trgm word_similarity (threshold: 0.3)
- Exact search when query is wrapped in quotes
- Field-specific autocomplete for dropdowns (author, genre, series, language)
- Proper pagination with configurable limit/offset

Implementation details:
- SearchMediaItems: Routes to SearchMediaItemsUnified query
  * Detects exact search by checking for quotes in query
  * Builds search pattern for ILIKE matching (%term%)
  * Converts string filters to pgtype.Text with proper Valid flags

- SearchFieldValues: Routes to appropriate field-specific query
  * Uses switch statement to call correct query based on field_type
  * Returns []FieldValue with value, count, and similarity score
  * Handles all 4 field types: author, genre, series, language

Design pattern: Service layer separates business logic from handlers,
following project's established architecture (FiltersService, CollectionService).
2026-03-22 20:35:09 -04:00
john-okeefe c3a98fb067 fix: use custom error type for saved filters not found
Fix failing test 'GET /api/saved-filters/:id_with_non-existent_filter_returns_404'
which was returning HTTP 500 instead of HTTP 404 due to string comparison
failure in error handling.

Root Cause:
- Service wrapped database error: fmt.Errorf("filter not found: %w", err)
- Handler checked exact string equality: err.Error() == "filter not found"
- Wrapped error message included database error: "filter not found: no rows in result set"
- String check failed → returned 500 instead of 404

Solution: Use Go error wrapping with custom error type

Changes to internal/services/filters.go:
- Add import: "errors" package
- Add custom error variable: ErrFilterNotFound
- Update GetSavedFilterByID() to return ErrFilterNotFound instead of wrapped error
- Error defined at service layer (domain authority)

Changes to internal/handlers/filters.go:
- Update error check from string comparison to errors.Is(err, services.ErrFilterNotFound)
- Uses Go's standard error wrapping pattern
- Cleaner, more maintainable, type-safe

Architectural Benefits:
-  Service layer owns domain errors (filter not found is a filter concept)
-  Handlers only translate service errors to HTTP status codes
-  Services reusable by any caller (API, WebSocket, CLI)
-  Clean dependency direction: Handlers → Services → Database
-  Follows Go best practices for error handling

Test Results:
- GET /api/saved-filters/:id with non-existent filter now returns 404
- Error message: "filter not found"
- No information leakage about other users' filters

Fixes test failure in TestSavedFilters.
2026-03-21 23:03:43 -04:00
john-okeefe 0960e36f30 feat: add GET /api/saved-filters/:id endpoint with comprehensive tests
Implement missing GET endpoint for retrieving individual saved filters by ID.
This completes the CRUD API for saved filters and enables mobile/SPA clients
to fetch filter details on-demand.

Backend Implementation:
- Add GetSavedFilterByID() handler method (internal/handlers/filters.go)
  - Parse filter ID from URL parameter
  - Validate UUID format, return 400 for invalid IDs
  - Call service layer for business logic + ownership verification
  - Return 404 if filter not found or doesn't belong to user
  - Return 200 with filter object including filters JSONB

- Add GetSavedFilterByID() service method (internal/services/filters.go)
  - Call existing database query GetSavedFilterByID
  - Verify filter exists and belongs to user
  - Return descriptive error: "filter not found or access denied"
  - Reuses existing database query (no new SQL needed)

- Register GET /:id route (internal/router/filters.go)
  - Add route before existing GET "" route
  - Follows RESTful routing conventions

Integration Tests (cmd/server/tests/filters_test.go):
- Test success case: Create filter, retrieve by ID, verify data
- Test error case: Invalid UUID format returns 400
- Test error case: Non-existent filter returns 404
- Test error case: No authentication returns 401
- Test security case: Cross-user access returns 404 (not 403)
  - Admin creates filter, regular user tries to access
  - Uses setup.Token (admin) and setup.RegularToken
  - Verifies information leakage prevention

API Design:
- Endpoint: GET /api/saved-filters/:id
- Authentication: JWT token required
- Response format: SavedFilterResponse with filters as JSON
- Error responses: 400 (invalid ID), 401 (no auth), 404 (not found)
- Security: Returns 404 for cross-user access (hides existence)

Benefits:
- Completes CRUD API for saved filters
- Enables future mobile/SPA clients
- Follows existing handler/service/test patterns
- Comprehensive security testing
- No database changes required (reuses existing queries)

Follows PROJECT_GUIDELINES.md service layer architecture and testing patterns.
2026-03-21 22:37:19 -04:00
john-okeefe 85964ec932 fix(api): enforce user isolation on saved filters delete operation
Fix critical security issue where admin users could delete other users'
saved filters due to incorrect error handling in DELETE query.

Database Schema Changes:
- Change DeleteSavedFilter from :exec to :one (queries.sql:1747-1750)
- Add RETURNING * to return deleted row for proper error detection
- Regenerate querier.go and queries.sql.go with updated signature

Service Layer (internal/services/filters.go):
- Update DeleteSavedFilter to capture returned row (using _ to discard)
- Properly propagate pgx.ErrNoRows when no rows are deleted
- Error wrapping preserves original error for handler detection

Handler Layer (internal/handlers/filters.go):
- Add errors.Is() check for pgx.ErrNoRows (line 148)
- Return 404 Not Found when filter doesn't exist or belongs to different user
- Return 500 Internal Server Error for other database errors
- Add "errors" import (line 8)

Security Fix Details:
Before: Admin could delete user's filter → 204 No Content (SUCCESS)
After:  Admin tries to delete user's filter → 404 Not Found (DENIED)

The DELETE query uses WHERE id = @id AND user_id = @user_id, which matches
0 rows when attempting to delete another user's filter. The old :exec query
didn't return row count, so 0 affected rows looked like success. The new :one
query with RETURNING * returns pgx.ErrNoRows when no rows match, allowing
the handler to return proper 404 error.

Test Impact:
- TestSavedFilters/User_cannot_access_another_user's_filter now passes
- All 6 integration tests pass with proper user isolation enforcement

Pattern Consistency:
- Matches DeleteLibraryFolder pattern (line 99 in queries.sql)
- Uses same error handling as media handlers (errors.Is + pgx.ErrNoRows)
- Follows user-scoping pattern used throughout codebase

Related: Saved filters implementation user isolation
Security: Prevents unauthorized deletion of user data
2026-03-21 01:24:15 -04:00