Bookmark dedup is keyed on hash(title + position bucket), but the table
also enforces UNIQUE(media_item_id, user_id, title). When a client re-
saves the same bookmark title with a changed position form - e.g. the
Android app upgrading a percentage-only row to an EPUB CFI, or a web and
app bookmark landing on the same 'Bookmark at 44%' title - the dedup-key
lookup misses and the INSERT violates the title constraint, returning
HTTP 500 and failing the sync.
A title collision on the same (user, item) is by definition the same
bookmark slot, so take the LWW semantics all the way: ON CONFLICT DO
UPDATE replaces position/cfi_position/page/chapter/percentage, refreshes
dedup_key and timestamps, merges device_sync_data, and - matching
UpdateMediaBookmarkForSync - clears deleted/deleted_at so a re-create
resurrects a tombstoned title slot instead of leaving an invisible row
holding it.
Device sync flows are unaffected: KOReader/Kobo pushes that carry their
own dedup-key echoes never reach the INSERT, and same-key saves still go
through applyBookmarkLWW with its tombstone freshness checks.
ListDeletedAnnotationsForBook unions tombstoned highlights, notes, and
bookmarks for a user+book regardless of the sync TTL cutoff (the history
must show everything still restorable, not just recent deletes), with
display text, secondary text, color, and both timestamps.
Restore queries clear deleted/deleted_at (lossless — the row was soft-
deleted, never removed) and are scoped to the owning user and media item
so a restore can never touch another user's annotation.
Purge queries hard-delete an already-tombstoned row: the user-driven
counterpart of the TTL maintenance sweep, for explicit 'delete
permanently' actions from the history.
All six write queries are :execrows so callers can distinguish 'restored'
from 'nothing matched' without a follow-up read.
GetTombstonedAnnotationsForBook now also returns each tombstone's
start_position/end_position and epubcfi_start/end (note: position/
epubcfi_location, bookmark: position/cfi_position), so serving code
can resolve a device-native locator for deletions of web-created
annotations, whose device_sync_data carries no pos0.
Deleting a bookmark/highlight/note and then re-adding the same content
at the same position (same dedup key — e.g. the reader's auto-titled
'Bookmark at X%') was silently swallowed: the save hit the tombstone
branch, returned 201 with the deleted row, and the list (which filters
deleted) stayed empty. Bookmarks were further blocked by the
UNIQUE(media_item_id, user_id, title) slot the tombstoned row holds,
and notes had no TTL escape at all.
Tombstones now only block saves that predate them (stale replays from
a device that still has the annotation). A save whose modification
time is newer than max(deleted_at, last_modified_at) — a deliberate
re-create from the web or a device — resurrects the row via the LWW
update queries, which now clear deleted/deleted_at.
Phase 0 of the reader redesign:
- Panels no longer render under the top/bottom bars: sidebars get
measured insets (same resize/safe-area mechanism as the viewport);
panel max-height now derives from the bounded sidebar instead of a
100vh guess; right-side border targets the actual sidebar.
- Bookmarks work end-to-end for the first time: REST CRUD under
/api/media-items/:id/bookmarks (create/delete route through
AnnotationService for dedup/LWW/tombstones), fix UpdateMediaBookmark
referencing nonexistent updated_at column, frontend posts to the
real API with per-format position (CFI vs page), live list with
jump + delete instead of SSR-only snapshot.
- Fix chapter matching in progress saves: boundaries were compared by
a nonexistent tocItem property, so chapter was never persisted.
- Remove dead UI: Navigator panel stub, empty dictionary popup shell,
unwired Chrome Behavior select; purge 160 stale build artifacts.
- Reader chrome now follows the user's app theme instead of hardcoded
theme-tokyo-night.
Content duplicates (same library + file_sha256 at different paths,
e.g. the same book imported twice under two names on a preexisting
database) cannot be auto-collapsed the way path duplicates were:
keeping both copies may be intentional. Surface them for an explicit
admin decision instead.
Schema:
- new hash_conflicts table keyed (library_id, file_sha256) with a
status/resolution lifecycle: 'pending' until an admin resolves via
'keep_all' or 'kept:<uuid>' (which copy was kept after merging)
- resolution is VARCHAR(50) - 'kept:<uuid>' is 41 chars; include a
widening ALTER for databases created with the initial 30-char width
- resolution/resolved_by/resolved_at record who decided what and when
Queries:
- ListMediaItemsMissingHash: items imported before hashing existed
(file_sha256 IS NULL), ordered oldest-first for the backfill pass
- FindHashConflictGroups: the content-duplicate group detection
(GROUP BY library_id, file_sha256 HAVING COUNT(*) > 1)
- ListMediaItemsBySHA256AndLibrary: full membership of one group
- CreateHashConflict: upsert with DO NOTHING so already-tracked groups
are untouched - critical behavior: a group an admin resolved as
'keep both' is never re-flagged by later sweeps
- ListPendingHashConflicts: admin listing with library name and live
item counts (items may have been deleted since flagging)
- GetHashConflict / ResolveHashConflict: lifecycle
- GetMediaItemUsageCounts: per-item progress/highlight/bookmark/note/
collection counts so the admin can make an informed keep choice
- ReparentMediaItemChildren: sqlc binding for the existing
reparent_media_item_children() migration function, used to merge a
losing copy's child rows into the kept copy
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.
Add a typed, cached registry over the system_settings table so that
values which used to be hardcoded Go literals can be changed at runtime.
Schema (database/schema/schema.sql):
- Extend system_settings with setting_type, min_value, max_value,
requires_restart, and category columns (all ADD COLUMN IF NOT EXISTS,
nullable for backward compat with the original three rows).
- Seed rows for every tunable: session duration, password rules,
login lockout, auth/device rate limits, OPDS page size, tombstone TTL,
conversion cache TTL, sync queue interval/batch, and worker pool
size/cap. Seed values equal the previous hardcoded literals, so
behavior is unchanged on upgrade. ON CONFLICT DO NOTHING preserves
any admin-modified values.
Queries (queries.sql):
- Add UpsertSystemSetting (RETURNING *) so new keys without a seed row
can still be written through the API.
- Add GetSystemSettingFull + GetAllSystemSettingsFull returning the
full typed row.
- Refactor CleanupExpiredRefreshTokens to take the retention window as
a parameter (make_interval(secs => $1)) instead of the INTERVAL '7
days' literal, so it can follow a configurable session duration.
Registry (internal/database/settings_registry.go):
- SettingsRegistry holds an in-memory cache of all known settings,
populated by Load at startup and refreshed by Reload on writes.
- Typed domain getters (SessionDuration, PasswordRules, DeviceRateLimits,
TombstoneTTL, OpdsPageSize, ConversionCacheTTL, SyncQueueConfig,
WorkerPoolConfig, LoginLockout, AuthRateLimit, ...) with compiled-in
fallback defaults and min/max clamping, so a corrupt or missing row
can never break the app.
- SettingDefaults is the single source of truth for keys, types, bounds,
and human descriptions; All() exposes metadata + current values for
the admin UI/API.
The registry lives in the database package (rather than its own
internal/settings package) because a quirk in this custom go1.26.5
toolchain prevented the large handlers package from importing any
newly-created package; every consumer already imports database.
Tests: settings_registry_test.go covers default validity per type,
int clamping at both bounds, garbage-value fallback, and unknown-key
lookup.
Add GetVisibleLibraryMediaCounts, which returns the media item count for
each library visible to a given user in a single GROUP BY query over
media_items. It mirrors the visibility logic in GetUserVisibleLibraries
(libraries default to visible unless an explicit false row exists) so
counts can be resolved in one round-trip instead of N per-library
lookups.
Regenerated sqlc bindings (querier.go, queries.sql.go).
Add migration columns to media_highlights, media_notes, and media_bookmarks
for cross-device annotation sync:
- dedup_key: SHA-1 of normalized selection text + bucketed position, used
as the stable cross-device identity for annotations
- last_modified_at / last_modified_source: edit clock for LWW resolution
and cross-source conflict detection
- deleted / deleted_at: sticky tombstone columns for delete-wins semantics
with a 30-day TTL before rows are physically purged
- note_text on highlights: stores attached notes from KOReader entries that
have both selected text and a user note
- Location columns on bookmarks (cfi_position, percentage_location,
epubcfi_location, chapter_reference, paragraph_reference)
- device_sync_data JSONB on all three tables: stores per-device native
identifiers (e.g. KOReader pos0/datetime, Kobo bookmark_id) so each
device can locate and manipulate its own copy of an annotation
Add partial unique indexes on (user_id, media_item_id, dedup_key) where
deleted = FALSE to enforce one active annotation per dedup key.
Add tombstone purge indexes on (deleted, deleted_at) for efficient GC.
New queries:
- GetByDedupKey for all three tables (returns active or most-recent tombstone)
- CreateFull / UpdateForSync for all three tables (populate sync columns)
- TombstoneByDedupKey / TombstoneByID for all three tables
- PurgeExpired* for all three tables (GC past TTL)
- GetActiveAnnotationsForBook (filtered union of highlights + notes)
- GetTombstonedAnnotationsForBook (union of all 3 deleted within TTL)
- GetMediaBookmarks with deleted filter
- GetMediaBookmark (singular) with deleted filter
- Added deleted=FALSE filter to GetMediaHighlights, GetAnnotationsForBook
- CreateAutoResolvedSyncConflict (INSERT with resolution_status='auto_resolved')
Setup completion was previously tracked by a manually-flipped setup_complete row in system_settings, written via a JWT-protected PUT /api/setup/complete endpoint. This meant any admin user created outside the setup wizard (future CLI, seed scripts, direct DB inserts) would not flip the switch, leaving the app stuck redirecting to /setup.
The trigger is now derived from real data: setup is complete iff at least one admin user exists. This is self-correcting regardless of how users are created, and re-engages setup automatically if all admins are ever removed.
Changes:
- Add internal/setupstatus package with IsSetupComplete() (queries CountAdmins, 10s in-memory cache, fails open on DB error) and Invalidate() to clear the cache. Uses an AdminCounter interface to avoid importing the database package.
- Add CountAdmins sqlc query (SELECT COUNT(*) FROM users WHERE role = 'admin') and regenerate.
- Rewire router/setup.go isSetupComplete() to delegate to setupstatus; drop the old setup_complete setting read, cache vars, and the PUT /api/setup/complete route.
- Call setupstatus.Invalidate() in the auth handler after CreateUser, UpdateUserRole, and DeleteUser so the cache reflects admin-count changes immediately.
- Align first-user promotion in Register to key off !adminExists instead of len(users) == 0, so the two checks cannot diverge.
- Remove the now-dead SetSetupComplete/GetSetupStatus handlers.
- Drop the setup_complete seed row from schema.sql.
- Remove the apiPut('/setup/complete') call from the setup wizard finishSetup(); the admin account created in submitAdmin already marks setup complete server-side.
Stores surrounding text (~100 chars) at the reader's current position.
Used as fallback for CFI resolution when converting between epubcfi
and CREngine XPointer formats.
Updates:
- schema.sql: add context_text TEXT column, update stored procedure
- queries.sql: add context_text to GetUniversalProgress and
UpdateUniversalProgress queries
- Regenerate sqlc Go code (models.go, querier.go, queries.sql.go)
Regenerated database code after sqlc version upgrade from v1.30.0 to
v1.31.1. No functional changes — only the version header in generated
files was updated.
Files: db.go, models.go, querier.go, queries.sql.go
Convert 12 SQL queries to use sqlc.narg('library_id') instead of
direct @library_id parameters. This allows passing a NULL/invalid
pgtype.UUID to mean "no library filter" (i.e., All Libraries),
making the SQL layer correctly handle the optional filter via:
(sqlc.narg('library_id')::uuid IS NULL
OR mi.library_id = sqlc.narg('library_id')::uuid)
Also remove the library_id filter from GetSeriesBooks entirely —
a series is a series regardless of library.
Queries affected:
- GetDashboardSections, GetRecentlyAdded, GetInProgress
- GetHighestRated, GetMostRead, GetAbandonedBooks
- GetLeastRead, GetBooksByTag, GetCollectionItemsForDashboard
- SearchMediaItemsUnified, GetSeriesCardsData
Generated code (queries.sql.go, querier.go) regenerated via sqlc.
The created_at column stores file modification time (intentional for preserving
original metadata), but this makes 'Recently Added' sorting unreliable for
imported files. Add imported_at column that records the actual database insert
timestamp.
Changes:
- Add imported_at TIMESTAMPTZ column to media_items (nullable)
- Update GetRecentlyAddedItems to sort by imported_at DESC NULLS LAST first
- Add ReassignLibraries and ReassignMediaItems queries for admin deletion handover
- Add SyncLibraryTypeExtensions query for startup extension sync
- Update all media_items SELECT queries to include imported_at column
The UpdateMediaItem SQL query only SET 23 of 37 media_items columns,
causing all 3 call sites (admin PUT, bulk update, force rescan) to
silently NULL out the 14 unwired fields on every update.
Added: manga_type, reading_direction, series_count, volume, imprint,
age_rating, web_url, metadata_notes, community_rating, story_arc,
is_black_and_white, alternate_info, scan_information, summary.
Regenerated sqlc Go code (queries.sql.go) with 37-param
UpdateMediaItemParams struct.
Add five new sqlc queries to support the series browse page and
continue-series dashboard collection:
- GetDistinctSeries: list unique series with book counts, sorted by
most recent entry, with pagination
- GetDistinctSeriesCount: total distinct series count for pagination
- GetSeriesCovers: fetch up to N cover image paths for a series,
ordered by series_number
- GetSeriesBooks: fetch all books in a series ordered by series_number
- GetContinueSeriesItems: CTE-based query using DISTINCT ON to find
the next unread book per series for a given user/library, sorted
by most recent last_read_at
The GetUser query did not select the timezone column, so the router
helper could not access userDB.Timezone. Added u.timezone to the
SELECT list so the per-user timezone is available in the template
user context.
- Add timezone column (VARCHAR(50) DEFAULT 'UTC') to users table
- Add default_timezone row to system_settings seed data
- Add idx_users_timezone index for user timezone lookups
- Add UpdateUserTimezone and GetSystemTimezone queries
- Regenerate sqlc code (models, querier, queries.sql.go)
- Reuse existing UpdateSystemSetting for system timezone updates
instead of creating a redundant UpdateSystemTimezone query
DismissAllResolved was calling ListSyncConflictsByUser which filters to
'unresolved' conflicts only, so it could never find the user_resolved or
bulk_resolved conflicts it was trying to delete. The query always returned
an empty set, making dismiss-all a no-op.
Fix the leading space in three SQL query name annotations (ListConflictsByUser,
ListAllConflictsByUserAndStatus, CheckForProgressConflicts) that prevented
sqlc from generating their Go functions. Regenerate the query code and swap
DismissAllResolved to use ListConflictsByUser (no status filter) — the
existing Go loop already filters by resolution_status.
- Add ProcessingIssues model struct
- Update Querier interface with processing issues methods
- Add generated query implementations for CreateProcessingIssue, ListProcessingIssuesByLibrary, GetProcessingIssueStats, ResolveProcessingIssue, DeleteProcessingIssue
- Add GetLibraryWithType query for fetching library with type information
- Add CreateProcessingIssue with upsert for recording/renewing issues
- Add ListProcessingIssuesByLibrary with severity ordering and media item details
- Add GetProcessingIssueStats for error/warning/info counts
- Add ResolveProcessingIssue for marking issues as resolved
- Add DeleteProcessingIssue for removing resolved issues
- Add GetLibraryWithType for fetching library with type info for validation
Remove unused Epubcfi and Percentage fields from UpdateReadingProgressParams
struct to align with the new foliate-js based reader implementation.
The foliate-js library handles CFI tracking and percentage calculation
internally, so these parameters are no longer needed in the update API.
The reader now relies on foliate-js's built-in progress tracking mechanisms.
This change aligns the database layer with the foliate-js integration completed
in commit c7a9098 (feat: Replace foliate-js submodule with npm git dependency).
Changes:
- Remove Epubcfi field from UpdateReadingProgressParams struct
- Remove Percentage field from UpdateReadingProgressParams struct
- UpdateReadingProgress function now uses simplified parameter set
Enhance reading progress tracking to support EPUB-specific location data:
- Add epubcfi field to store EPUB Canonical Fragment Identifier
- Add percentage field for normalized position across formats
- Update UpdateReadingProgress API handler to accept new fields
- Modify database queries to persist additional progress metadata
This enables precise position tracking in reflowable EPUB content
where page numbers are insufficient for accurate bookmarking.
- Add library_type_name VARCHAR(50) column to media_items table in schema.sql
- Create PostgreSQL trigger 'set_library_type_name_on_insert' that automatically
populates library_type_name by joining libraries table with library_types on insert
- Add library_type_name parameter to CreateMediaItem SQL query
- Update Go models (models.go) to include LibraryTypeName field
- Add UpdateMediaItemChapterMetadata query method to querier.go
- Regenerate queries.sql.go with sqlc
This allows media items to store their library type (e.g., 'Books', 'Comics', 'Manga')
at the database level, enabling filtering and display without needing additional joins.
Database changes:
- Add unique index on saved_filters(user_id, name, resource_type)
Prevents duplicate filter names while allowing same name across
different users or different resource types
Search functionality fix:
- Remove DISTINCT ON (mi.id) from SearchMediaItemsUnified query
- Remove mi.id from ORDER BY clause (was required by DISTINCT ON)
- This allows user-selected sort field to be primary sort criteria
- Previously results were always sorted by ID first, making sort
dropdown ineffective
- Relevance score and title remain as fallback sorts
This fixes the sort dropdown functionality on the bookshelf page
where changing the sort option appeared to have no effect.
Fixed the SearchMediaItemsUnified query to properly handle the has_cover
parameter in three states:
- NULL (not specified): Show all books
- TRUE: Show only books with cover images
- FALSE: Show only books without cover images
Changes:
- Added explicit boolean casting (::bool) to sqlc.narg('has_cover')
to resolve PostgreSQL type inference error (SQLSTATE 42P08)
- Replaced single AND condition with OR'd logic to handle all three
states without mutual exclusion
- Used IS NULL check to detect when parameter is not specified
- Used IS TRUE/IS FALSE to explicitly check boolean states
The previous implementation had mutually exclusive AND conditions that
prevented any records from matching when has_cover was explicitly set
to TRUE or FALSE, causing the filter to block all searches.
This fix resolves the issue where searches were returning 0 results
regardless of other filter parameters when has_cover was included in
the query.
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
- Change all tag.value references to tag in SearchTagsValues query
- Fix PostgreSQL error: "column tag.value does not exist"
- CROSS JOIN LATERAL unnest() creates alias 'tag', not 'tag.value'
- Updates SELECT, WHERE, GROUP BY, and ORDER BY clauses
- Regenerate Go code with sqlc generate
When using CROSS JOIN LATERAL unnest(mi.tags_search) AS tag,
PostgreSQL creates 'tag' as the column alias, not 'tag.value'.
This fix aligns all references to use just 'tag', matching the
actual column name created by the LATERAL join.
Resolves tags autocomplete SQLSTATE 42703 error.
Relates to TestTagsFilter tags autocomplete test
- Fix SearchTagsValues query to use CROSS JOIN LATERAL instead of unnest() in WHERE clause
- PostgreSQL error: "set-returning functions are not allowed in WHERE"
- Change from direct unnest() calls to a proper lateral join pattern
- References: tag.value instead of repeated unnest(mi.tags_search) calls
- Regenerate Go code with sqlc generate
This fixes the tags autocomplete functionality which was failing with
SQLSTATE 0A000 error. The CROSS JOIN LATERAL approach properly expands
the tags array before filtering, allowing set-returning functions to
work correctly in the query.
Relates to TestTagsFilter tags autocomplete test
- Add tags_filter parameter to SearchMediaItemsUnified
- Add EXISTS clause with word_similarity() for fuzzy tag matching
- Add tag similarity scoring to ORDER BY clause (GREATEST function)
- Add SearchTagsValues query for autocomplete with ::TEXT cast
- Keep genre_filter for backward compatibility
- Regenerate Go code with sqlc generate
This enables filtering books by tags (from Calibre) instead of genre,
which is always NULL for imported books. Uses fuzzy matching consistent
with author/series filters, with best matches sorted first.
Relates to IMPLEMENTATION_TAGS_FILTER.md Phase 1
Problem:
The search API was returning duplicate media items when searching across
libraries. For example, searching for "Harry" with 2 books would return
4-8 results instead of 2, depending on how many users had library visibility
entries.
Root Cause:
The SearchMediaItemsUnified query uses a LEFT JOIN with library_visibility:
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
When multiple library_visibility entries exist for the same library
(e.g., one per user during testing), the LEFT JOIN can create duplicate
rows for each media_item. The query didn't have a DISTINCT clause to
eliminate these duplicates.
Solution:
Added DISTINCT ON (mi.id) clause with mi.id as the first ORDER BY expression:
SELECT DISTINCT ON (mi.id) mi.*, ...
FROM media_items mi
...
ORDER BY mi.id, <other_sort_criteria>
This ensures that even if the LEFT JOIN produces multiple rows per
media_item, only one row per mi.id is returned, preserving the first
occurrence based on the relevance sorting.
Impact:
- Search results now correctly return unique media items
- Test TestCollectionSearchLibraryFilter will pass after database cleanup
- No API changes required - this is purely a query optimization
Note: After deploying this change, residual test data should be cleaned up
with: docker-compose down -v && docker-compose up -d
Files changed:
- internal/database/queries/queries.sql: Added DISTINCT ON clause
- internal/database/queries.sql.go: Regenerated from sqlc
Updates SearchMediaItemsUnified query to support searching across all
libraries when library_id parameter is not provided. Changes SQL from
requiring library_id to checking for NULL:
AND (sqlc.narg('library_id')::uuid IS NULL
OR mi.library_id = sqlc.narg('library_id')::uuid)
The explicit ::uuid cast ensures PostgreSQL handles type inference
correctly when comparing UUID columns with nullable parameters.
Regenerates Go database code including queries.sql.go and querier.go
to reflect the updated SQL schema.
This enables the /api/media-items/search endpoint to search all libraries
by omitting the library_id query parameter, matching the behavior of
the OPDS search endpoint.
- Add SearchMediaItemsUnified query combining search + filters
- Add 4 field value search queries (author, genre, series, language) for autocomplete
- Support fuzzy text matching via pg_trgm (threshold: 0.3 similarity)
- Support exact match with quotes detection for search queries
- Add sort parameter support (title ASC/DESC, author ASC/DESC, created_at ASC/DESC, page_count ASC/DESC)
- Primary sort by relevance score when searching, secondary by user-specified sort
- Combine search query with all filter types in single optimized query
- Uses 4 separate simple queries instead of 1 complex query due to sqlc v1.30.0 limitation with CASE in GROUP BY
This consolidates the deprecated /filtered and /search endpoints into one unified endpoint.
Run sqlc generate to create Go code for new search queries:
Added methods to Querier interface:
- SearchMediaItemsUnified - Main unified search with fuzzy/exact matching
- SearchAuthorValues - Author field autocomplete
- SearchGenreValues - Genre field autocomplete
- SearchSeriesValues - Series field autocomplete
- SearchLanguageValues - Language field autocomplete
Generated parameter structs and row types for all new queries.
All queries include proper library visibility checks.
Add comprehensive search queries supporting both fuzzy and exact matching:
1. SearchMediaItemsUnified - Main search query with:
- Fuzzy matching on author, series, genre, language filters
- Fuzzy search on title, author, series, tags, contributors
- Exact matching with quotes (is_exact_search flag)
- Year range and boolean filters
- Relevance-based ordering using word_similarity scores
2. Field-specific autocomplete queries:
- SearchAuthorValues, SearchGenreValues, SearchSeriesValues, SearchLanguageValues
- Each returns distinct values with counts and similarity scores
- Threshold of 0.3 for word_similarity filter
- Ordered by relevance (score DESC, count DESC)
Note: Using 4 separate field value queries instead of 1 complex query
due to sqlc v1.30.0 limitation with CASE expressions in GROUP BY clauses.
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
Add database schema and SQL queries for generic saved filters system
that allows users to save custom filter presets for any resource type.
Database Schema:
- Add saved_filters table with user_id, name, resource_type, filters (JSONB)
- Create composite index on (user_id, resource_type) for efficient lookups
- Create index on (user_id, name) for future name search feature
- Add update_updated_at_column() trigger to auto-update timestamps
- Make trigger creation idempotent with DROP TRIGGER IF EXISTS
SQL Queries (5 new queries):
- GetSavedFilters: List all filters for user + resource type
- GetSavedFilterByID: Retrieve single filter by ID
- CreateSavedFilter: Create new saved filter
- UpdateSavedFilter: Update filter name/criteria
- DeleteSavedFilter: Remove saved filter
Design Decisions:
- Generic resource_type field supports any resource (media-items, collections, devices)
- JSONB filters field allows flexible schema without migrations
- User-scoped via JWT (user_id foreign key with CASCADE delete)
- Automatic updated_at timestamp via database trigger
Generated Code:
- database.SavedFilters model (10 fields including JSONB filters)
- All 5 CRUD query functions with proper parameter types
- pgtype.UUID wrappers for UUID parameters
Part of: Saved Filters Implementation (Phase 1: Database)
Related: #saved-filters-feature
- Change library_id parameter from interface{} to pgtype.UUID
- Add explicit UUID type casting in SQL queries
- Fix SearchMediaItemsParams to use strongly-typed UUID
- Prevents potential type assertion errors and improves type safety
- Ensures proper NULL handling for optional library_id filter
This commit adds comprehensive functionality for filtering collections by library,
improves WebSocket real-time updates with user activity detection, and adds
extensive test coverage.
## Core Features
### Collection Library Filter
- Added library_id parameter to media-items search API
- Collections can now be filtered by specific library
- Toggle UI component for enabling/disabling library filter
- Default state is "checked" when library_id is present
- Consistent behavior across partial and fuzzy search modes
### WebSocket Auto-Reload Mitigation
- Added user activity detection to prevent disruptive page reloads
- Checks if user is actively typing in INPUT/TEXTAREA/SELECT elements
- Skips auto-reload when user is interacting with form elements
- Toast notifications still show for awareness
- Prevents data loss during editing operations
## Implementation Changes
### Backend
- internal/database/queries.sql.go: Added library filter support to search queries
- internal/handlers/media.go: Enhanced search with library_id parameter validation
- internal/handlers/collections.go: Updated collection handlers with library filtering
- internal/sync/websocket.go: Improved broadcast mechanism with user-scoped updates
- internal/router/frontend.go: Pass libraryID to collection templates
### Frontend
- templates/collections.templ: Added library filter toggle UI component
- web/src/collections.ts: TypeScript implementation with WebSocket integration
- templates/collections_templ.go: Generated template code
### Testing
- cmd/server/tests/search_test.go: Added TestCollectionSearchLibraryFilter
- cmd/server/tests/websocket_test.go: Added TestWebSocketUserScopedBroadcast
- New helper functions for creating libraries and media items via API
- Comprehensive test coverage for library filtering and user-scoped broadcasts
## API Documentation Updates
### Bruno Tests (Comprehensive Documentation)
- bruno/collections/*: Added detailed API documentation for all collection endpoints
- bruno/devices/*: Added device management and sync API documentation
- bruno/devices/kobo/api.yml: Kobo-specific sync protocol docs
- bruno/devices/koreader/api.yml: KOReader-specific sync protocol docs
- bruno/opds/*: Added OPDS feed and download endpoint documentation
- bruno/library/browse-folders.yml: Library folder browsing API docs
### New Bruno Tests
- bruno/media-items/Search All Libraries.yml: Test search without library filter
- bruno/media-items/Search Specific Library.yml: Test search with library filter
- bruno/media-items/Search Invalid Library ID.yml: Test error handling
## Documentation
- docs/developer/api/media-items/search_media_items.md: Updated with library_id parameter
- IMPLEMENTATION_COLLECTION_FIX.md: Comprehensive implementation guide with test scenarios
## Testing
### Integration Tests
- Library filter tests verify correct filtering across multiple libraries
- Invalid library_id tests ensure proper error handling
- WebSocket tests verify user-scoped broadcast behavior
- User A no longer receives User B's collection updates
### Manual Testing Scenarios
- Open collection in multiple tabs - updates propagate correctly
- Type in search box while another tab adds books - no disruptive reload
- Add/remove books from collection - toast notifications appear
- Toggle library filter - results update dynamically
## Technical Details
- WebSocket broadcasts are now user-scoped for privacy
- Active element detection uses tagName and contenteditable attributes
- Library ID validation uses UUID format checking
- Progressive enhancement maintained - page works without JavaScript
- All changes follow PROJECT_GUIDELINES.md conventions
- TypeScript only for frontend logic
- TailwindCSS only for styling
- Procedural programming style throughout
## Breaking Changes
None - all changes are additive and backward compatible.
Change library ordering in dropdown from DESC to ASC to display
libraries in creation order (oldest first).
Database changes in internal/database/queries/queries.sql:
- Modify GetUserLibraries query ORDER BY clause
- Change from ORDER BY l.created_at DESC to ASC
- Displays oldest libraries first in dropdown
This provides a more intuitive ordering where users see their
first-created libraries at the top of the list.
- 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.