Compare commits

..
3 Commits
Author SHA1 Message Date
John O'Keefe 217f411f74 fix(auth): GetProfile reads the user row — the JWT context stub has no theme
GetProfile returned the theme from the JWT-claims context stub, which
carries only id/email/username/role — user.Theme was always empty, so
the profile response omitted the field and the app's account-theme
read-back (SERVER sync mode) could never see the stored theme. GetProfile
now loads the full user row by id (h.db.GetUser).
2026-09-28 08:45:01 -04:00
John O'Keefe 4fde2a6d83 feat(auth): GetProfile returns the account theme
The Android app's app-chrome theme (UX pass item 6) reads the account
theme from GET /api/auth/profile and re-applies it at startup in
SERVER sync mode. PUT /api/auth/theme already persisted the value;
this completes the round trip on the profile read.
2026-09-28 08:34:11 -04:00
John O'Keefe 3d88f0b960 feat(search): publisher_filter for the media search endpoint
Adds publisher_filter to SearchMediaItems/SearchMediaItemsUnified
(fuzzy word_similarity against mi.publisher, mirroring genre_filter),
plumbs it through services.SearchParams and the search handler, and
extends the GREATEST relevance ranking to include publisher matches.
Serves the app's author/publisher/genre/tag click-through browses
(UX pass item 5 — publisher was the only facet without a server
filter).
2026-09-27 19:55:58 -04:00
5 changed files with 116 additions and 95 deletions
+60 -55
View File
@@ -11285,46 +11285,48 @@ WHERE COALESCE(lv.is_visible, true) = true
AND ($4 = '' OR word_similarity($4, COALESCE(mi.series, '')) > 0.3)
-- Fuzzy genre filter
AND ($5 = '' OR word_similarity($5, COALESCE(mi.genre, '')) > 0.3)
-- Fuzzy publisher filter
AND ($6 = '' OR word_similarity($6, COALESCE(mi.publisher, '')) > 0.3)
-- Fuzzy language filter
AND ($6 = '' OR word_similarity($6, COALESCE(mi.language, '')) > 0.3)
AND ($7 = '' OR word_similarity($7, COALESCE(mi.language, '')) > 0.3)
-- Tags filter (NEW - fuzzy match against tags array)
AND ($7 = '' OR EXISTS (
AND ($8 = '' OR EXISTS (
SELECT 1 FROM unnest(mi.tags_search) AS tag
WHERE word_similarity($7, tag) > 0.3
WHERE word_similarity($8, tag) > 0.3
))
-- Year range (exact) - prioritize date_published, fallback to copyright_year
AND (
$8 = 0 OR
EXTRACT(YEAR FROM mi.date_published) >= $8 OR
(mi.date_published IS NULL AND mi.copyright_year >= $8)
$9 = 0 OR
EXTRACT(YEAR FROM mi.date_published) >= $9 OR
(mi.date_published IS NULL AND mi.copyright_year >= $9)
)
AND (
$9 = 0 OR
EXTRACT(YEAR FROM mi.date_published) <= $9 OR
(mi.date_published IS NULL AND mi.copyright_year <= $9)
$10 = 0 OR
EXTRACT(YEAR FROM mi.date_published) <= $10 OR
(mi.date_published IS NULL AND mi.copyright_year <= $10)
)
-- Boolean (exact)
AND (
$10::bool IS NULL OR -- Not specified = show all
($10::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
($10::bool IS FALSE AND mi.cover_image_path IS NULL)
$11::bool IS NULL OR -- Not specified = show all
($11::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
($11::bool IS FALSE AND mi.cover_image_path IS NULL)
)
-- Search query (fuzzy or exact based on quotes)
AND (
$11 = '' OR
$12 = '' OR
-- Fuzzy search (default)
$12 = false AND (
word_similarity($11, mi.title) > 0.3 OR
word_similarity($11, COALESCE(mi.author, '')) > 0.3 OR
word_similarity($11, COALESCE(mi.series, '')) > 0.3 OR
$13 = false AND (
word_similarity($12, mi.title) > 0.3 OR
word_similarity($12, COALESCE(mi.author, '')) > 0.3 OR
word_similarity($12, COALESCE(mi.series, '')) > 0.3 OR
EXISTS (
SELECT 1 FROM unnest(mi.tags_search) AS tag
WHERE word_similarity($11, tag) > 0.3
WHERE word_similarity($12, tag) > 0.3
LIMIT 1
) OR
EXISTS (
SELECT 1 FROM unnest(mi.contributors_search) AS contributor
WHERE word_similarity($11, contributor) > 0.3
WHERE word_similarity($12, contributor) > 0.3
LIMIT 1
)
) OR
@@ -11338,86 +11340,88 @@ WHERE COALESCE(lv.is_visible, true) = true
-- sqlc.narg('search_pattern') = ANY(mi.contributors_search)
-- )
-- Exact search (with quotes) - true exact match, not substring
$12 = true AND (
mi.title = $11 OR
COALESCE(mi.author, '') = $11 OR
COALESCE(mi.series, '') = $11 OR
$11 = ANY(mi.tags_search) OR
$11 = ANY(mi.contributors_search)
$13 = true AND (
mi.title = $12 OR
COALESCE(mi.author, '') = $12 OR
COALESCE(mi.series, '') = $12 OR
$12 = ANY(mi.tags_search) OR
$12 = ANY(mi.contributors_search)
)
)
ORDER BY
-- Primary sort: relevance score when searching
CASE
WHEN $11 != '' THEN
WHEN $12 != '' THEN
GREATEST(
CASE WHEN $12 = false THEN
word_similarity($11, mi.title)
CASE WHEN $13 = false THEN
word_similarity($12, mi.title)
ELSE 0 END,
CASE WHEN $12 = false THEN
word_similarity($11, COALESCE(mi.author, ''))
CASE WHEN $13 = false THEN
word_similarity($12, COALESCE(mi.author, ''))
ELSE 0 END,
word_similarity($3, COALESCE(mi.author, '')),
word_similarity($5, COALESCE(mi.genre, '')),
(SELECT MAX(word_similarity($7, tag))
word_similarity($6, COALESCE(mi.publisher, '')),
(SELECT MAX(word_similarity($8, tag))
FROM unnest(mi.tags_search) AS tag)
)
ELSE 0
END DESC,
-- Secondary sort: user-specified sort parameter
CASE
WHEN $13 = 'title ASC' THEN mi.title
WHEN $14 = 'title ASC' THEN mi.title
ELSE ''
END ASC,
CASE
WHEN $13 = 'title DESC' THEN mi.title
WHEN $14 = 'title DESC' THEN mi.title
ELSE ''
END DESC,
CASE
WHEN $13 = 'author ASC' THEN COALESCE(mi.author, '')
WHEN $14 = 'author ASC' THEN COALESCE(mi.author, '')
ELSE ''
END ASC,
CASE
WHEN $13 = 'author DESC' THEN COALESCE(mi.author, '')
WHEN $14 = 'author DESC' THEN COALESCE(mi.author, '')
ELSE ''
END DESC,
CASE
WHEN $13 = 'created_at ASC' THEN mi.created_at
WHEN $14 = 'created_at ASC' THEN mi.created_at
ELSE '1970-01-01'::timestamp
END ASC,
CASE
WHEN $13 = 'created_at DESC' THEN mi.created_at
WHEN $14 = 'created_at DESC' THEN mi.created_at
ELSE '1970-01-01'::timestamp
END DESC,
CASE
WHEN $13 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
WHEN $14 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
ELSE ''
END ASC,
CASE
WHEN $13 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
WHEN $14 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
ELSE ''
END DESC,
-- Tertiary sort: title (default fallback)
mi.title ASC
LIMIT $15 OFFSET $14
LIMIT $16 OFFSET $15
`
type SearchMediaItemsUnifiedParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
AuthorFilter interface{} `db:"author_filter" json:"author_filter"`
SeriesFilter interface{} `db:"series_filter" json:"series_filter"`
GenreFilter interface{} `db:"genre_filter" json:"genre_filter"`
LanguageFilter interface{} `db:"language_filter" json:"language_filter"`
TagsFilter interface{} `db:"tags_filter" json:"tags_filter"`
YearMin interface{} `db:"year_min" json:"year_min"`
YearMax interface{} `db:"year_max" json:"year_max"`
HasCover pgtype.Bool `db:"has_cover" json:"has_cover"`
SearchQuery interface{} `db:"search_query" json:"search_query"`
IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"`
Sort interface{} `db:"sort" json:"sort"`
Offset pgtype.Int4 `db:"offset" json:"offset"`
Limit pgtype.Int4 `db:"limit" json:"limit"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
AuthorFilter interface{} `db:"author_filter" json:"author_filter"`
SeriesFilter interface{} `db:"series_filter" json:"series_filter"`
GenreFilter interface{} `db:"genre_filter" json:"genre_filter"`
PublisherFilter interface{} `db:"publisher_filter" json:"publisher_filter"`
LanguageFilter interface{} `db:"language_filter" json:"language_filter"`
TagsFilter interface{} `db:"tags_filter" json:"tags_filter"`
YearMin interface{} `db:"year_min" json:"year_min"`
YearMax interface{} `db:"year_max" json:"year_max"`
HasCover pgtype.Bool `db:"has_cover" json:"has_cover"`
SearchQuery interface{} `db:"search_query" json:"search_query"`
IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"`
Sort interface{} `db:"sort" json:"sort"`
Offset pgtype.Int4 `db:"offset" json:"offset"`
Limit pgtype.Int4 `db:"limit" json:"limit"`
}
type SearchMediaItemsUnifiedRow struct {
@@ -11476,6 +11480,7 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt
arg.AuthorFilter,
arg.SeriesFilter,
arg.GenreFilter,
arg.PublisherFilter,
arg.LanguageFilter,
arg.TagsFilter,
arg.YearMin,
+3
View File
@@ -628,6 +628,8 @@ WHERE COALESCE(lv.is_visible, true) = true
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
-- Fuzzy genre filter
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
-- Fuzzy publisher filter
AND (sqlc.narg('publisher_filter') = '' OR word_similarity(sqlc.narg('publisher_filter'), COALESCE(mi.publisher, '')) > 0.3)
-- Fuzzy language filter
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
-- Tags filter (NEW - fuzzy match against tags array)
@@ -702,6 +704,7 @@ ORDER BY
ELSE 0 END,
word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')),
word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')),
word_similarity(sqlc.narg('publisher_filter'), COALESCE(mi.publisher, '')),
(SELECT MAX(word_similarity(sqlc.narg('tags_filter'), tag))
FROM unnest(mi.tags_search) AS tag)
)
+10 -1
View File
@@ -101,6 +101,7 @@ type UserProfile struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Role string `json:"role"`
Theme string `json:"theme,omitempty"`
}
type UpdateProfileRequest struct {
@@ -485,7 +486,14 @@ window.location.href = '%s';
// GetProfile handles GET /api/auth/profile
func (h *AuthHandler) GetProfile(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
ctxUser := MustGetAuthenticatedUser(c)
// The context user is a JWT-claims stub (id/email/username/role) —
// it carries no theme. Read the full row: clients (the Android app)
// sync their app-chrome theme from this field.
user, err := h.db.GetUser(c.Request().Context(), ctxUser.ID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load profile"})
}
firstName := ""
if user.FirstName.Valid {
@@ -502,6 +510,7 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error {
FirstName: firstName,
LastName: lastName,
Role: user.Role,
Theme: user.Theme.String,
})
}
+16 -14
View File
@@ -2133,6 +2133,7 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
authorFilter := c.QueryParam("author_filter")
seriesFilter := c.QueryParam("series_filter")
genreFilter := c.QueryParam("genre_filter")
publisherFilter := c.QueryParam("publisher_filter")
tagsFilter := c.QueryParam("tags_filter")
languageFilter := c.QueryParam("language_filter")
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
@@ -2152,20 +2153,21 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
// Build search params
params := services.SearchParams{
UserID: userID.ID,
LibraryID: libUUID,
SearchQuery: query,
AuthorFilter: authorFilter,
SeriesFilter: seriesFilter,
GenreFilter: genreFilter,
TagsFilter: tagsFilter,
LanguageFilter: languageFilter,
YearMin: yearMin,
YearMax: yearMax,
HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid},
Sort: sortParam,
Limit: limit,
Offset: offset,
UserID: userID.ID,
LibraryID: libUUID,
SearchQuery: query,
AuthorFilter: authorFilter,
SeriesFilter: seriesFilter,
GenreFilter: genreFilter,
PublisherFilter: publisherFilter,
TagsFilter: tagsFilter,
LanguageFilter: languageFilter,
YearMin: yearMin,
YearMax: yearMax,
HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid},
Sort: sortParam,
Limit: limit,
Offset: offset,
}
c.Logger().Info("SearchMediaItems called",
+27 -25
View File
@@ -22,20 +22,21 @@ func NewSearchService(db *database.Queries) *SearchService {
// SearchParams contains parameters for unified search
type SearchParams struct {
UserID pgtype.UUID
LibraryID pgtype.UUID
AuthorFilter string
SeriesFilter string
GenreFilter string
TagsFilter string
LanguageFilter string
YearMin int
YearMax int
HasCover pgtype.Bool
SearchQuery string
Sort string
Limit int
Offset int
UserID pgtype.UUID
LibraryID pgtype.UUID
AuthorFilter string
SeriesFilter string
GenreFilter string
PublisherFilter string
TagsFilter string
LanguageFilter string
YearMin int
YearMax int
HasCover pgtype.Bool
SearchQuery string
Sort string
Limit int
Offset int
}
// parseSearchQuery detects quoted strings for exact match search
@@ -69,17 +70,18 @@ func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params Sear
// Build database parameters - only set valid true if filter
dbParams := database.SearchMediaItemsUnifiedParams{
UserID: params.UserID,
AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true},
SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true},
GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true},
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true},
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
HasCover: params.HasCover,
SearchQuery: pgtype.Text{String: searchQuery, Valid: true},
IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true},
UserID: params.UserID,
AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true},
SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true},
GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true},
PublisherFilter: pgtype.Text{String: params.PublisherFilter, Valid: true},
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true},
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
HasCover: params.HasCover,
SearchQuery: pgtype.Text{String: searchQuery, Valid: true},
IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true},
// SearchPattern: pgtype.Text{String: searchPattern, Valid: isExact},
Sort: pgtype.Text{String: params.Sort, Valid: true},
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},