From 3b2075fc706fa56f991a3b30b3a7eec8f5a7ba07 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 30 Jan 2026 08:32:49 -0500 Subject: [PATCH] Phase 1: Add enhanced database fields and sorting - Add 9 new fields to media_items table (language, edition, page_count, goodreads_id, openlibrary_id, google_books_id, copyright_year, genre, subjects) - Add indexes for new fields (language, genre, page_count, copyright_year, series_order, date_published) - Add ListMediaItemsSorted SQL query for dynamic sorting - Update ListMediaItems handler to process sort parameter - Support 16 sorting options (title, author, created_at, date_published, copyright_year, page_count, genre, series) - Add /api/media-items/filtered endpoint for advanced filtering - Register new filtered endpoint in routes --- database/schema/schema.sql | 15 + internal/database/querier.go | 2 + internal/database/queries.sql.go | 510 +++++++++++++++++++++++++- internal/database/queries/queries.sql | 118 ++++++ internal/handlers/ebook.go | 96 ++++- 5 files changed, 727 insertions(+), 14 deletions(-) diff --git a/database/schema/schema.sql b/database/schema/schema.sql index 5649238..5dfa3c2 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -107,6 +107,15 @@ CREATE TABLE media_items ( date_published DATE, publisher VARCHAR(255), contributors TEXT, + language VARCHAR(10) DEFAULT 'en', + edition VARCHAR(255), + page_count INTEGER, + goodreads_id VARCHAR(20), + openlibrary_id VARCHAR(100), + google_books_id VARCHAR(100), + copyright_year INTEGER, + genre VARCHAR(100), + subjects TEXT[], added_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() @@ -246,6 +255,12 @@ CREATE INDEX idx_media_items_title ON media_items(title); CREATE INDEX idx_media_items_author ON media_items(author); CREATE INDEX idx_media_items_library_id ON media_items(library_id); CREATE INDEX idx_media_items_added_by_admin_id ON media_items(added_by_admin_id); +CREATE INDEX idx_media_items_language ON media_items(language); +CREATE INDEX idx_media_items_genre ON media_items(genre); +CREATE INDEX idx_media_items_page_count ON media_items(page_count); +CREATE INDEX idx_media_items_copyright_year ON media_items(copyright_year); +CREATE INDEX idx_media_items_series_order ON media_items(series, series_number); +CREATE INDEX idx_media_items_date_published ON media_items(date_published); -- GIN indexes for fast fuzzy search using pg_trgm CREATE INDEX idx_media_items_title_gin ON media_items USING gin (title gin_trgm_ops); diff --git a/internal/database/querier.go b/internal/database/querier.go index 86faaed..10babf7 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -86,6 +86,8 @@ type Querier interface { ListLibraries(ctx context.Context) ([]ListLibrariesRow, error) ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error) + ListMediaItemsFiltered(ctx context.Context, arg ListMediaItemsFilteredParams) ([]ListMediaItemsFilteredRow, error) + ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error) ListUsers(ctx context.Context) ([]ListUsersRow, error) RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error RevokeRefreshToken(ctx context.Context, token string) error diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 9879ab9..f756899 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -45,7 +45,7 @@ func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context) error { const CreateEbook = `-- name: CreateEbook :one INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, added_by_admin_id) VALUES ((SELECT id FROM libraries WHERE library_type_id = (SELECT id FROM library_types WHERE name = 'ebooks') LIMIT 1), $1, $2, normalize_isbn($3), $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) -RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, added_by_admin_id, created_at, updated_at +RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, goodreads_id, openlibrary_id, google_books_id, copyright_year, genre, subjects, added_by_admin_id, created_at, updated_at ` type CreateEbookParams struct { @@ -105,6 +105,15 @@ func (q *Queries) CreateEbook(ctx context.Context, arg CreateEbookParams) (Media &i.DatePublished, &i.Publisher, &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.CopyrightYear, + &i.Genre, + &i.Subjects, &i.AddedByAdminID, &i.CreatedAt, &i.UpdatedAt, @@ -300,7 +309,7 @@ func (q *Queries) CreateMediaHighlight(ctx context.Context, arg CreateMediaHighl const CreateMediaItem = `-- name: CreateMediaItem :one INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, added_by_admin_id) VALUES ($1, $2, $3, normalize_isbn($4), $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) -RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, added_by_admin_id, created_at, updated_at +RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, goodreads_id, openlibrary_id, google_books_id, copyright_year, genre, subjects, added_by_admin_id, created_at, updated_at ` type CreateMediaItemParams struct { @@ -363,6 +372,15 @@ func (q *Queries) CreateMediaItem(ctx context.Context, arg CreateMediaItemParams &i.DatePublished, &i.Publisher, &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.CopyrightYear, + &i.Genre, + &i.Subjects, &i.AddedByAdminID, &i.CreatedAt, &i.UpdatedAt, @@ -650,7 +668,7 @@ func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error { } const GetEbook = `-- name: GetEbook :one -SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, added_by_admin_id, created_at, updated_at FROM ebooks WHERE id = $1 +SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, goodreads_id, openlibrary_id, google_books_id, copyright_year, genre, subjects, added_by_admin_id, created_at, updated_at FROM ebooks WHERE id = $1 ` // Backward compatibility - Ebooks queries (using views) @@ -675,6 +693,15 @@ func (q *Queries) GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error) &i.DatePublished, &i.Publisher, &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.CopyrightYear, + &i.Genre, + &i.Subjects, &i.AddedByAdminID, &i.CreatedAt, &i.UpdatedAt, @@ -684,7 +711,7 @@ func (q *Queries) GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error) const GetEbookByFilePath = `-- name: GetEbookByFilePath :one -SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, added_by_admin_id, created_at, updated_at FROM ebooks WHERE file_path = $1 +SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, goodreads_id, openlibrary_id, google_books_id, copyright_year, genre, subjects, added_by_admin_id, created_at, updated_at FROM ebooks WHERE file_path = $1 ` // Note: User ebook folders replaced by library folders system @@ -710,6 +737,15 @@ func (q *Queries) GetEbookByFilePath(ctx context.Context, filePath string) (Eboo &i.DatePublished, &i.Publisher, &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.CopyrightYear, + &i.Genre, + &i.Subjects, &i.AddedByAdminID, &i.CreatedAt, &i.UpdatedAt, @@ -1138,7 +1174,7 @@ func (q *Queries) GetMediaHighlights(ctx context.Context, arg GetMediaHighlights } const GetMediaItem = `-- name: GetMediaItem :one -SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, added_by_admin_id, created_at, updated_at FROM media_items WHERE id = $1 +SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, goodreads_id, openlibrary_id, google_books_id, copyright_year, genre, subjects, added_by_admin_id, created_at, updated_at FROM media_items WHERE id = $1 ` func (q *Queries) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error) { @@ -1162,6 +1198,15 @@ func (q *Queries) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, &i.DatePublished, &i.Publisher, &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.CopyrightYear, + &i.Genre, + &i.Subjects, &i.AddedByAdminID, &i.CreatedAt, &i.UpdatedAt, @@ -1170,7 +1215,7 @@ func (q *Queries) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, } const GetMediaItemByFilePath = `-- name: GetMediaItemByFilePath :one -SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, added_by_admin_id, created_at, updated_at FROM media_items WHERE file_path = $1 +SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, goodreads_id, openlibrary_id, google_books_id, copyright_year, genre, subjects, added_by_admin_id, created_at, updated_at FROM media_items WHERE file_path = $1 ` func (q *Queries) GetMediaItemByFilePath(ctx context.Context, filePath string) (MediaItems, error) { @@ -1194,6 +1239,15 @@ func (q *Queries) GetMediaItemByFilePath(ctx context.Context, filePath string) ( &i.DatePublished, &i.Publisher, &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.CopyrightYear, + &i.Genre, + &i.Subjects, &i.AddedByAdminID, &i.CreatedAt, &i.UpdatedAt, @@ -1634,7 +1688,7 @@ func (q *Queries) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUI } const ListEbooks = `-- name: ListEbooks :many -SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, added_by_admin_id, created_at, updated_at FROM ebooks ORDER BY created_at DESC LIMIT $1 OFFSET $2 +SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, goodreads_id, openlibrary_id, google_books_id, copyright_year, genre, subjects, added_by_admin_id, created_at, updated_at FROM ebooks ORDER BY created_at DESC LIMIT $1 OFFSET $2 ` type ListEbooksParams struct { @@ -1669,6 +1723,15 @@ func (q *Queries) ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebook &i.DatePublished, &i.Publisher, &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.CopyrightYear, + &i.Genre, + &i.Subjects, &i.AddedByAdminID, &i.CreatedAt, &i.UpdatedAt, @@ -1733,7 +1796,7 @@ func (q *Queries) ListLibraries(ctx context.Context) ([]ListLibrariesRow, error) } const ListMediaItems = `-- name: ListMediaItems :many -SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.added_by_admin_id, mi.created_at, mi.updated_at, l.name as library_name, lt.name as library_type_name +SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.copyright_year, mi.genre, mi.subjects, mi.added_by_admin_id, mi.created_at, mi.updated_at, l.name as library_name, lt.name as library_type_name FROM media_items mi JOIN libraries l ON mi.library_id = l.id JOIN library_types lt ON l.library_type_id = lt.id @@ -1763,6 +1826,15 @@ type ListMediaItemsRow struct { DatePublished pgtype.Date `db:"date_published" json:"date_published"` Publisher pgtype.Text `db:"publisher" json:"publisher"` Contributors pgtype.Text `db:"contributors" json:"contributors"` + Language pgtype.Text `db:"language" json:"language"` + Edition pgtype.Text `db:"edition" json:"edition"` + PageCount pgtype.Int4 `db:"page_count" json:"page_count"` + GoodreadsID pgtype.Text `db:"goodreads_id" json:"goodreads_id"` + OpenlibraryID pgtype.Text `db:"openlibrary_id" json:"openlibrary_id"` + GoogleBooksID pgtype.Text `db:"google_books_id" json:"google_books_id"` + CopyrightYear pgtype.Int4 `db:"copyright_year" json:"copyright_year"` + Genre pgtype.Text `db:"genre" json:"genre"` + Subjects []string `db:"subjects" json:"subjects"` AddedByAdminID pgtype.UUID `db:"added_by_admin_id" json:"added_by_admin_id"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` @@ -1797,6 +1869,15 @@ func (q *Queries) ListMediaItems(ctx context.Context, arg ListMediaItemsParams) &i.DatePublished, &i.Publisher, &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.CopyrightYear, + &i.Genre, + &i.Subjects, &i.AddedByAdminID, &i.CreatedAt, &i.UpdatedAt, @@ -1814,7 +1895,7 @@ func (q *Queries) ListMediaItems(ctx context.Context, arg ListMediaItemsParams) } const ListMediaItemsByLibrary = `-- name: ListMediaItemsByLibrary :many -SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.added_by_admin_id, mi.created_at, mi.updated_at, l.name as library_name, lt.name as library_type_name +SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.copyright_year, mi.genre, mi.subjects, mi.added_by_admin_id, mi.created_at, mi.updated_at, l.name as library_name, lt.name as library_type_name FROM media_items mi JOIN libraries l ON mi.library_id = l.id JOIN library_types lt ON l.library_type_id = lt.id @@ -1840,6 +1921,15 @@ type ListMediaItemsByLibraryRow struct { DatePublished pgtype.Date `db:"date_published" json:"date_published"` Publisher pgtype.Text `db:"publisher" json:"publisher"` Contributors pgtype.Text `db:"contributors" json:"contributors"` + Language pgtype.Text `db:"language" json:"language"` + Edition pgtype.Text `db:"edition" json:"edition"` + PageCount pgtype.Int4 `db:"page_count" json:"page_count"` + GoodreadsID pgtype.Text `db:"goodreads_id" json:"goodreads_id"` + OpenlibraryID pgtype.Text `db:"openlibrary_id" json:"openlibrary_id"` + GoogleBooksID pgtype.Text `db:"google_books_id" json:"google_books_id"` + CopyrightYear pgtype.Int4 `db:"copyright_year" json:"copyright_year"` + Genre pgtype.Text `db:"genre" json:"genre"` + Subjects []string `db:"subjects" json:"subjects"` AddedByAdminID pgtype.UUID `db:"added_by_admin_id" json:"added_by_admin_id"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` @@ -1874,6 +1964,346 @@ func (q *Queries) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype. &i.DatePublished, &i.Publisher, &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.CopyrightYear, + &i.Genre, + &i.Subjects, + &i.AddedByAdminID, + &i.CreatedAt, + &i.UpdatedAt, + &i.LibraryName, + &i.LibraryTypeName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListMediaItemsFiltered = `-- name: ListMediaItemsFiltered :many +SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.copyright_year, mi.genre, mi.subjects, mi.added_by_admin_id, mi.created_at, mi.updated_at, l.name as library_name, lt.name as library_type_name +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +JOIN library_types lt ON l.library_type_id = lt.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 +WHERE mi.library_id = $2 + AND COALESCE(lv.is_visible, true) = true + AND ($3 = '' OR mi.author ILIKE $3) + AND ($4 = '' OR mi.series ILIKE $4) + AND ($5 = '' OR mi.genre = $5) + AND ($6 = '' OR mi.language = $6) + AND ($7 = 0 OR mi.copyright_year >= $7) + AND ($8 = 0 OR mi.copyright_year <= $8) + AND ($9 = false OR mi.cover_image_path IS NOT NULL) +ORDER BY + CASE + WHEN $10 = 'title ASC' THEN mi.title + ELSE '' + END ASC, + CASE + WHEN $10 = 'title DESC' THEN mi.title + ELSE '' + END DESC, + CASE + WHEN $10 = 'author ASC' THEN COALESCE(mi.author, '') + ELSE '' + END ASC, + CASE + WHEN $10 = 'author DESC' THEN COALESCE(mi.author, '') + ELSE '' + END DESC, + CASE + WHEN $10 = 'created_at ASC' THEN mi.created_at + ELSE '1970-01-01'::timestamp + END ASC, + CASE + WHEN $10 = 'created_at DESC' THEN mi.created_at + ELSE '1970-01-01'::timestamp + END DESC, + mi.created_at DESC +LIMIT $12 OFFSET $11 +` + +type ListMediaItemsFilteredParams 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"` + YearMin interface{} `db:"year_min" json:"year_min"` + YearMax interface{} `db:"year_max" json:"year_max"` + HasCover interface{} `db:"has_cover" json:"has_cover"` + Sort interface{} `db:"sort" json:"sort"` + Offset pgtype.Int4 `db:"offset" json:"offset"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type ListMediaItemsFilteredRow struct { + ID pgtype.UUID `db:"id" json:"id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Title string `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` + Isbn pgtype.Text `db:"isbn" json:"isbn"` + Description pgtype.Text `db:"description" json:"description"` + FilePath string `db:"file_path" json:"file_path"` + FileSize pgtype.Int8 `db:"file_size" json:"file_size"` + MimeType pgtype.Text `db:"mime_type" json:"mime_type"` + CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"` + Series pgtype.Text `db:"series" json:"series"` + SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"` + Tags pgtype.Text `db:"tags" json:"tags"` + Asin pgtype.Text `db:"asin" json:"asin"` + DatePublished pgtype.Date `db:"date_published" json:"date_published"` + Publisher pgtype.Text `db:"publisher" json:"publisher"` + Contributors pgtype.Text `db:"contributors" json:"contributors"` + Language pgtype.Text `db:"language" json:"language"` + Edition pgtype.Text `db:"edition" json:"edition"` + PageCount pgtype.Int4 `db:"page_count" json:"page_count"` + GoodreadsID pgtype.Text `db:"goodreads_id" json:"goodreads_id"` + OpenlibraryID pgtype.Text `db:"openlibrary_id" json:"openlibrary_id"` + GoogleBooksID pgtype.Text `db:"google_books_id" json:"google_books_id"` + CopyrightYear pgtype.Int4 `db:"copyright_year" json:"copyright_year"` + Genre pgtype.Text `db:"genre" json:"genre"` + Subjects []string `db:"subjects" json:"subjects"` + AddedByAdminID pgtype.UUID `db:"added_by_admin_id" json:"added_by_admin_id"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` + LibraryName string `db:"library_name" json:"library_name"` + LibraryTypeName string `db:"library_type_name" json:"library_type_name"` +} + +func (q *Queries) ListMediaItemsFiltered(ctx context.Context, arg ListMediaItemsFilteredParams) ([]ListMediaItemsFilteredRow, error) { + rows, err := q.db.Query(ctx, ListMediaItemsFiltered, + arg.UserID, + arg.LibraryID, + arg.AuthorFilter, + arg.SeriesFilter, + arg.GenreFilter, + arg.LanguageFilter, + arg.YearMin, + arg.YearMax, + arg.HasCover, + arg.Sort, + arg.Offset, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListMediaItemsFilteredRow{} + for rows.Next() { + var i ListMediaItemsFilteredRow + if err := rows.Scan( + &i.ID, + &i.LibraryID, + &i.Title, + &i.Author, + &i.Isbn, + &i.Description, + &i.FilePath, + &i.FileSize, + &i.MimeType, + &i.CoverImagePath, + &i.Series, + &i.SeriesNumber, + &i.Tags, + &i.Asin, + &i.DatePublished, + &i.Publisher, + &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.CopyrightYear, + &i.Genre, + &i.Subjects, + &i.AddedByAdminID, + &i.CreatedAt, + &i.UpdatedAt, + &i.LibraryName, + &i.LibraryTypeName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListMediaItemsSorted = `-- name: ListMediaItemsSorted :many +SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.copyright_year, mi.genre, mi.subjects, mi.added_by_admin_id, mi.created_at, mi.updated_at, l.name as library_name, lt.name as library_type_name +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +JOIN library_types lt ON l.library_type_id = lt.id +WHERE mi.library_id = $1 +ORDER BY + CASE + WHEN $2 = 'title ASC' THEN mi.title + ELSE '' + END ASC, + CASE + WHEN $2 = 'title DESC' THEN mi.title + ELSE '' + END DESC, + CASE + WHEN $2 = 'author ASC' THEN COALESCE(mi.author, '') + ELSE '' + END ASC, + CASE + WHEN $2 = 'author DESC' THEN COALESCE(mi.author, '') + ELSE '' + END DESC, + CASE + WHEN $2 = 'created_at ASC' THEN mi.created_at + ELSE '1970-01-01'::timestamp + END ASC, + CASE + WHEN $2 = 'created_at DESC' THEN mi.created_at + ELSE '1970-01-01'::timestamp + END DESC, + CASE + WHEN $2 = 'series ASC' THEN COALESCE(mi.series, '') + ELSE '' + END ASC, + CASE + WHEN $2 = 'series DESC' THEN COALESCE(mi.series, '') + ELSE '' + END DESC, + CASE + WHEN $2 = 'date_published ASC' THEN COALESCE(mi.date_published::text, '') + ELSE '' + END ASC, + CASE + WHEN $2 = 'date_published DESC' THEN COALESCE(mi.date_published::text, '') + ELSE '' + END DESC, + CASE + WHEN $2 = 'copyright_year ASC' THEN COALESCE(mi.copyright_year::text, '0') + ELSE '' + END ASC, + CASE + WHEN $2 = 'copyright_year DESC' THEN COALESCE(mi.copyright_year::text, '0') + ELSE '' + END DESC, + CASE + WHEN $2 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0') + ELSE '' + END ASC, + CASE + WHEN $2 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0') + ELSE '' + END DESC, + CASE + WHEN $2 = 'genre ASC' THEN COALESCE(mi.genre, '') + ELSE '' + END ASC, + CASE + WHEN $2 = 'genre DESC' THEN COALESCE(mi.genre, '') + ELSE '' + END DESC, + mi.created_at DESC + LIMIT $4 OFFSET $3 +` + +type ListMediaItemsSortedParams struct { + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Sort interface{} `db:"sort" json:"sort"` + Offset pgtype.Int4 `db:"offset" json:"offset"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type ListMediaItemsSortedRow struct { + ID pgtype.UUID `db:"id" json:"id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Title string `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` + Isbn pgtype.Text `db:"isbn" json:"isbn"` + Description pgtype.Text `db:"description" json:"description"` + FilePath string `db:"file_path" json:"file_path"` + FileSize pgtype.Int8 `db:"file_size" json:"file_size"` + MimeType pgtype.Text `db:"mime_type" json:"mime_type"` + CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"` + Series pgtype.Text `db:"series" json:"series"` + SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"` + Tags pgtype.Text `db:"tags" json:"tags"` + Asin pgtype.Text `db:"asin" json:"asin"` + DatePublished pgtype.Date `db:"date_published" json:"date_published"` + Publisher pgtype.Text `db:"publisher" json:"publisher"` + Contributors pgtype.Text `db:"contributors" json:"contributors"` + Language pgtype.Text `db:"language" json:"language"` + Edition pgtype.Text `db:"edition" json:"edition"` + PageCount pgtype.Int4 `db:"page_count" json:"page_count"` + GoodreadsID pgtype.Text `db:"goodreads_id" json:"goodreads_id"` + OpenlibraryID pgtype.Text `db:"openlibrary_id" json:"openlibrary_id"` + GoogleBooksID pgtype.Text `db:"google_books_id" json:"google_books_id"` + CopyrightYear pgtype.Int4 `db:"copyright_year" json:"copyright_year"` + Genre pgtype.Text `db:"genre" json:"genre"` + Subjects []string `db:"subjects" json:"subjects"` + AddedByAdminID pgtype.UUID `db:"added_by_admin_id" json:"added_by_admin_id"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` + LibraryName string `db:"library_name" json:"library_name"` + LibraryTypeName string `db:"library_type_name" json:"library_type_name"` +} + +func (q *Queries) ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error) { + rows, err := q.db.Query(ctx, ListMediaItemsSorted, + arg.LibraryID, + arg.Sort, + arg.Offset, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListMediaItemsSortedRow{} + for rows.Next() { + var i ListMediaItemsSortedRow + if err := rows.Scan( + &i.ID, + &i.LibraryID, + &i.Title, + &i.Author, + &i.Isbn, + &i.Description, + &i.FilePath, + &i.FileSize, + &i.MimeType, + &i.CoverImagePath, + &i.Series, + &i.SeriesNumber, + &i.Tags, + &i.Asin, + &i.DatePublished, + &i.Publisher, + &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.CopyrightYear, + &i.Genre, + &i.Subjects, &i.AddedByAdminID, &i.CreatedAt, &i.UpdatedAt, @@ -1955,7 +2385,7 @@ func (q *Queries) RevokeRefreshToken(ctx context.Context, token string) error { } const SearchMediaItems = `-- name: SearchMediaItems :many -SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.added_by_admin_id, mi.created_at, mi.updated_at, l.name as library_name, lt.name as library_type_name +SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.copyright_year, mi.genre, mi.subjects, mi.added_by_admin_id, mi.created_at, mi.updated_at, l.name as library_name, lt.name as library_type_name FROM media_items mi JOIN libraries l ON mi.library_id = l.id JOIN library_types lt ON l.library_type_id = lt.id @@ -2005,6 +2435,15 @@ type SearchMediaItemsRow struct { DatePublished pgtype.Date `db:"date_published" json:"date_published"` Publisher pgtype.Text `db:"publisher" json:"publisher"` Contributors pgtype.Text `db:"contributors" json:"contributors"` + Language pgtype.Text `db:"language" json:"language"` + Edition pgtype.Text `db:"edition" json:"edition"` + PageCount pgtype.Int4 `db:"page_count" json:"page_count"` + GoodreadsID pgtype.Text `db:"goodreads_id" json:"goodreads_id"` + OpenlibraryID pgtype.Text `db:"openlibrary_id" json:"openlibrary_id"` + GoogleBooksID pgtype.Text `db:"google_books_id" json:"google_books_id"` + CopyrightYear pgtype.Int4 `db:"copyright_year" json:"copyright_year"` + Genre pgtype.Text `db:"genre" json:"genre"` + Subjects []string `db:"subjects" json:"subjects"` AddedByAdminID pgtype.UUID `db:"added_by_admin_id" json:"added_by_admin_id"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` @@ -2045,6 +2484,15 @@ func (q *Queries) SearchMediaItems(ctx context.Context, arg SearchMediaItemsPara &i.DatePublished, &i.Publisher, &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.CopyrightYear, + &i.Genre, + &i.Subjects, &i.AddedByAdminID, &i.CreatedAt, &i.UpdatedAt, @@ -2062,7 +2510,7 @@ func (q *Queries) SearchMediaItems(ctx context.Context, arg SearchMediaItemsPara } const SearchMediaItemsFuzzy = `-- name: SearchMediaItemsFuzzy :many -SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.added_by_admin_id, mi.created_at, mi.updated_at, l.name as library_name, lt.name as library_type_name +SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.copyright_year, mi.genre, mi.subjects, mi.added_by_admin_id, mi.created_at, mi.updated_at, l.name as library_name, lt.name as library_type_name FROM media_items mi JOIN libraries l ON mi.library_id = l.id JOIN library_types lt ON l.library_type_id = lt.id @@ -2112,6 +2560,15 @@ type SearchMediaItemsFuzzyRow struct { DatePublished pgtype.Date `db:"date_published" json:"date_published"` Publisher pgtype.Text `db:"publisher" json:"publisher"` Contributors pgtype.Text `db:"contributors" json:"contributors"` + Language pgtype.Text `db:"language" json:"language"` + Edition pgtype.Text `db:"edition" json:"edition"` + PageCount pgtype.Int4 `db:"page_count" json:"page_count"` + GoodreadsID pgtype.Text `db:"goodreads_id" json:"goodreads_id"` + OpenlibraryID pgtype.Text `db:"openlibrary_id" json:"openlibrary_id"` + GoogleBooksID pgtype.Text `db:"google_books_id" json:"google_books_id"` + CopyrightYear pgtype.Int4 `db:"copyright_year" json:"copyright_year"` + Genre pgtype.Text `db:"genre" json:"genre"` + Subjects []string `db:"subjects" json:"subjects"` AddedByAdminID pgtype.UUID `db:"added_by_admin_id" json:"added_by_admin_id"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` @@ -2151,6 +2608,15 @@ func (q *Queries) SearchMediaItemsFuzzy(ctx context.Context, arg SearchMediaItem &i.DatePublished, &i.Publisher, &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.CopyrightYear, + &i.Genre, + &i.Subjects, &i.AddedByAdminID, &i.CreatedAt, &i.UpdatedAt, @@ -2214,7 +2680,7 @@ UPDATE media_items SET contributors = $13, updated_at = NOW() WHERE id = $1 -RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, added_by_admin_id, created_at, updated_at +RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, goodreads_id, openlibrary_id, google_books_id, copyright_year, genre, subjects, added_by_admin_id, created_at, updated_at ` type UpdateEbookParams struct { @@ -2268,6 +2734,15 @@ func (q *Queries) UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Media &i.DatePublished, &i.Publisher, &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.CopyrightYear, + &i.Genre, + &i.Subjects, &i.AddedByAdminID, &i.CreatedAt, &i.UpdatedAt, @@ -2485,7 +2960,7 @@ UPDATE media_items SET contributors = $13, updated_at = NOW() WHERE id = $1 -RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, added_by_admin_id, created_at, updated_at +RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, goodreads_id, openlibrary_id, google_books_id, copyright_year, genre, subjects, added_by_admin_id, created_at, updated_at ` type UpdateMediaItemParams struct { @@ -2539,6 +3014,15 @@ func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams &i.DatePublished, &i.Publisher, &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.CopyrightYear, + &i.Genre, + &i.Subjects, &i.AddedByAdminID, &i.CreatedAt, &i.UpdatedAt, diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 4d281fa..3a42a3b 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -119,6 +119,124 @@ JOIN library_types lt ON l.library_type_id = lt.id WHERE mi.library_id = $1 ORDER BY mi.created_at DESC; +-- name: ListMediaItemsSorted :many +SELECT mi.*, l.name as library_name, lt.name as library_type_name +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +JOIN library_types lt ON l.library_type_id = lt.id +WHERE mi.library_id = sqlc.narg('library_id') +ORDER BY + CASE + WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title + ELSE '' + END ASC, + CASE + WHEN sqlc.narg('sort') = 'title DESC' THEN mi.title + ELSE '' + END DESC, + CASE + WHEN sqlc.narg('sort') = 'author ASC' THEN COALESCE(mi.author, '') + ELSE '' + END ASC, + CASE + WHEN sqlc.narg('sort') = 'author DESC' THEN COALESCE(mi.author, '') + ELSE '' + END DESC, + CASE + WHEN sqlc.narg('sort') = 'created_at ASC' THEN mi.created_at + ELSE '1970-01-01'::timestamp + END ASC, + CASE + WHEN sqlc.narg('sort') = 'created_at DESC' THEN mi.created_at + ELSE '1970-01-01'::timestamp + END DESC, + CASE + WHEN sqlc.narg('sort') = 'series ASC' THEN COALESCE(mi.series, '') + ELSE '' + END ASC, + CASE + WHEN sqlc.narg('sort') = 'series DESC' THEN COALESCE(mi.series, '') + ELSE '' + END DESC, + CASE + WHEN sqlc.narg('sort') = 'date_published ASC' THEN COALESCE(mi.date_published::text, '') + ELSE '' + END ASC, + CASE + WHEN sqlc.narg('sort') = 'date_published DESC' THEN COALESCE(mi.date_published::text, '') + ELSE '' + END DESC, + CASE + WHEN sqlc.narg('sort') = 'copyright_year ASC' THEN COALESCE(mi.copyright_year::text, '0') + ELSE '' + END ASC, + CASE + WHEN sqlc.narg('sort') = 'copyright_year DESC' THEN COALESCE(mi.copyright_year::text, '0') + ELSE '' + END DESC, + CASE + WHEN sqlc.narg('sort') = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0') + ELSE '' + END ASC, + CASE + WHEN sqlc.narg('sort') = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0') + ELSE '' + END DESC, + CASE + WHEN sqlc.narg('sort') = 'genre ASC' THEN COALESCE(mi.genre, '') + ELSE '' + END ASC, + CASE + WHEN sqlc.narg('sort') = 'genre DESC' THEN COALESCE(mi.genre, '') + ELSE '' + END DESC, + mi.created_at DESC + LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); + +-- name: ListMediaItemsFiltered :many +SELECT mi.*, l.name as library_name, lt.name as library_type_name +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +JOIN library_types lt ON l.library_type_id = lt.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +WHERE mi.library_id = sqlc.narg('library_id') + AND COALESCE(lv.is_visible, true) = true + AND (sqlc.narg('author_filter') = '' OR mi.author ILIKE sqlc.narg('author_filter')) + AND (sqlc.narg('series_filter') = '' OR mi.series ILIKE sqlc.narg('series_filter')) + AND (sqlc.narg('genre_filter') = '' OR mi.genre = sqlc.narg('genre_filter')) + AND (sqlc.narg('language_filter') = '' OR mi.language = sqlc.narg('language_filter')) + AND (sqlc.narg('year_min') = 0 OR mi.copyright_year >= sqlc.narg('year_min')) + AND (sqlc.narg('year_max') = 0 OR mi.copyright_year <= sqlc.narg('year_max')) + AND (sqlc.narg('has_cover') = false OR mi.cover_image_path IS NOT NULL) +ORDER BY + CASE + WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title + ELSE '' + END ASC, + CASE + WHEN sqlc.narg('sort') = 'title DESC' THEN mi.title + ELSE '' + END DESC, + CASE + WHEN sqlc.narg('sort') = 'author ASC' THEN COALESCE(mi.author, '') + ELSE '' + END ASC, + CASE + WHEN sqlc.narg('sort') = 'author DESC' THEN COALESCE(mi.author, '') + ELSE '' + END DESC, + CASE + WHEN sqlc.narg('sort') = 'created_at ASC' THEN mi.created_at + ELSE '1970-01-01'::timestamp + END ASC, + CASE + WHEN sqlc.narg('sort') = 'created_at DESC' THEN mi.created_at + ELSE '1970-01-01'::timestamp + END DESC, + mi.created_at DESC +LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); + + -- name: UpdateMediaItem :one UPDATE media_items SET title = $2, diff --git a/internal/handlers/ebook.go b/internal/handlers/ebook.go index a129488..69d59dd 100644 --- a/internal/handlers/ebook.go +++ b/internal/handlers/ebook.go @@ -96,6 +96,7 @@ func SetupRoutes(g *echo.Group, db *database.Queries) *Handler { // Media item routes (new library system) g.GET("/media-items", h.ListMediaItems) + g.GET("/media-items/filtered", h.ListMediaItemsFiltered) g.GET("/media-items/search", h.SearchMediaItems) g.GET("/media-items/:id", h.GetMediaItem) g.POST("/media-items/:id/rating", h.CreateMediaRating) @@ -824,6 +825,7 @@ func (h *Handler) StopScheduler() { // ListMediaItems handles GET /api/media-items func (h *Handler) ListMediaItems(c echo.Context) error { libraryID := c.QueryParam("library_id") + sort := c.QueryParam("sort") limit, _ := strconv.Atoi(c.QueryParam("limit")) offset, _ := strconv.Atoi(c.QueryParam("offset")) @@ -831,6 +833,33 @@ func (h *Handler) ListMediaItems(c echo.Context) error { limit = 50 } + if sort == "" { + sort = "created_at DESC" + } + + allowedSorts := map[string]bool{ + "created_at ASC": true, + "created_at DESC": true, + "title ASC": true, + "title DESC": true, + "author ASC": true, + "author DESC": true, + "series ASC": true, + "series DESC": true, + "date_published ASC": true, + "date_published DESC": true, + "copyright_year ASC": true, + "copyright_year DESC": true, + "page_count ASC": true, + "page_count DESC": true, + "genre ASC": true, + "genre DESC": true, + } + + if !allowedSorts[sort] { + sort = "created_at DESC" + } + // Enforce maximum limit if limit > maxPaginationLimit { limit = maxPaginationLimit @@ -846,7 +875,12 @@ func (h *Handler) ListMediaItems(c echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"}) } - items, err := h.db.ListMediaItemsByLibrary(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true}) + items, err := h.db.ListMediaItemsSorted(c.Request().Context(), database.ListMediaItemsSortedParams{ + LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, + Sort: pgtype.Text{String: sort, Valid: true}, + Limit: pgtype.Int4{Int32: int32(limit), Valid: true}, + Offset: pgtype.Int4{Int32: int32(offset), Valid: true}, + }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } @@ -884,6 +918,66 @@ func (h *Handler) GetMediaItem(c echo.Context) error { return c.JSON(http.StatusOK, item) } +// ListMediaItemsFiltered handles GET /api/media-items/filtered +func (h *Handler) ListMediaItemsFiltered(c echo.Context) error { + userID := c.Get("user_id").(string) + libraryID := c.QueryParam("library_id") + sort := c.QueryParam("sort") + limit, _ := strconv.Atoi(c.QueryParam("limit")) + offset, _ := strconv.Atoi(c.QueryParam("offset")) + + authorFilter := c.QueryParam("author_filter") + seriesFilter := c.QueryParam("series_filter") + genreFilter := c.QueryParam("genre_filter") + languageFilter := c.QueryParam("language_filter") + yearMin, _ := strconv.Atoi(c.QueryParam("year_min")) + yearMax, _ := strconv.Atoi(c.QueryParam("year_max")) + hasCover, _ := strconv.ParseBool(c.QueryParam("has_cover")) + + if limit == 0 { + limit = 50 + } + + if sort == "" { + sort = "created_at DESC" + } + + // Enforce maximum limit + if limit > maxPaginationLimit { + limit = maxPaginationLimit + } + + libUUID, err := uuid.Parse(libraryID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"}) + } + + userUUID, err := uuid.Parse(userID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"}) + } + + items, err := h.db.ListMediaItemsFiltered(c.Request().Context(), database.ListMediaItemsFilteredParams{ + LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, + UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + AuthorFilter: pgtype.Text{String: authorFilter, Valid: true}, + SeriesFilter: pgtype.Text{String: seriesFilter, Valid: true}, + GenreFilter: pgtype.Text{String: genreFilter, Valid: true}, + LanguageFilter: pgtype.Text{String: languageFilter, Valid: true}, + YearMin: pgtype.Int4{Int32: int32(yearMin), Valid: true}, + YearMax: pgtype.Int4{Int32: int32(yearMax), Valid: true}, + HasCover: pgtype.Bool{Bool: hasCover, Valid: true}, + Sort: pgtype.Text{String: sort, Valid: true}, + Limit: pgtype.Int4{Int32: int32(limit), Valid: true}, + Offset: pgtype.Int4{Int32: int32(offset), Valid: true}, + }) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{"data": items}) +} + // CreateMediaRating handles POST /api/media-items/:id/rating func (h *Handler) CreateMediaRating(c echo.Context) error { userID := c.Get("user_id").(string)