Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
services:

postgres:
image: postgres
image: postgres:16
volumes:
- pg-data:/var/lib/postgresql/data
environment:
Expand Down
17 changes: 13 additions & 4 deletions internal/controller/http/opds/opds.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package opds
import (
"encoding/xml"
"fmt"
"net/url"
"time"

"github.com/vanadium23/kompanion/internal/entity"
Expand Down Expand Up @@ -120,29 +121,37 @@ func truncateText(text string, maxLen int) string {
return text[:maxLen-3] + "..."
}

func formNavLinks(baseURL string, books library.PaginatedBookList) []Link {
// formNavLinks creates navigation links for OPDS pagination.
// If searchQuery is not empty, it is included in the pagination links.
func formNavLinks(baseURL string, searchQuery string, books library.PaginatedBookList) []Link {
// Build query string prefix
queryPrefix := "?"
if searchQuery != "" {
queryPrefix = fmt.Sprintf("?search=%s&", url.QueryEscape(searchQuery))
}

links := []Link{
{
Href: baseURL,
Type: DirMime,
Rel: "start",
},
{
Href: fmt.Sprintf("%s?page=%d", baseURL, books.Last()),
Href: fmt.Sprintf("%spage=%d", queryPrefix, books.Last()),
Type: DirMime,
Rel: "last",
},
}
if books.HasNext() {
links = append(links, Link{
Href: fmt.Sprintf("%s?page=%d", baseURL, books.Next()),
Href: fmt.Sprintf("%spage=%d", queryPrefix, books.Next()),
Type: DirMime,
Rel: "next",
})
}
if books.HasPrev() {
links = append(links, Link{
Href: fmt.Sprintf("%s?page=%d", baseURL, books.Prev()),
Href: fmt.Sprintf("%spage=%d", queryPrefix, books.Prev()),
Type: DirMime,
Rel: "prev",
})
Expand Down
28 changes: 25 additions & 3 deletions internal/controller/http/opds/router.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package opds

import (
"fmt"
"net/http"
"strconv"
"time"

"github.com/gin-gonic/gin"
"github.com/vanadium23/kompanion/internal/auth"
"github.com/vanadium23/kompanion/internal/entity"
"github.com/vanadium23/kompanion/internal/library"
"github.com/vanadium23/kompanion/internal/sync"
"github.com/vanadium23/kompanion/pkg/logger"
Expand Down Expand Up @@ -60,16 +62,36 @@ func (r *OPDSRouter) listNewest(c *gin.Context) {
if err != nil {
page = 1
}
books, err := r.books.ListBooks(c.Request.Context(), "created_at", "desc", page, 10)

// Parse search query parameter
searchQuery := c.Query("search")

query := entity.SearchQuery{
Search: searchQuery,
SortBy: "created_at",
SortOrder: "desc",
Page: page,
Limit: 10,
}
books, err := r.books.ListBooks(c.Request.Context(), query)
if err != nil {
r.logger.Error("failed to list newest books", err)
c.JSON(http.StatusInternalServerError, gin.H{"message": "Internal server error", "code": 1001})
return
}
baseUrl := "/opds/newest/"

// Build self URL including search query if present
selfUrl := baseUrl
feedTitle := "KOmpanion library"
if searchQuery != "" {
selfUrl = fmt.Sprintf("%s?search=%s", baseUrl, searchQuery)
feedTitle = fmt.Sprintf("KOmpanion library - Search: %s", searchQuery)
}

entries := translateBooksToEntries(books.Books)
navLinks := formNavLinks(baseUrl, books)
feed := BuildFeed("urn:kompanion:newest", "KOmpanion library", baseUrl, entries, navLinks)
navLinks := formNavLinks(baseUrl, searchQuery, books)
feed := BuildFeed("urn:kompanion:newest", feedTitle, selfUrl, entries, navLinks)
c.XML(http.StatusOK, feed)
}

Expand Down
136 changes: 136 additions & 0 deletions internal/controller/http/v1/books.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package v1

import (
"net/http"
"strconv"

"github.com/gin-gonic/gin"
"github.com/vanadium23/kompanion/internal/auth"
"github.com/vanadium23/kompanion/internal/entity"
"github.com/vanadium23/kompanion/internal/library"
"github.com/vanadium23/kompanion/pkg/logger"
)

type booksRoutes struct {
shelf library.Shelf
l logger.Interface
}

func newBooksRoutes(handler *gin.RouterGroup, shelf library.Shelf, a auth.AuthInterface, l logger.Interface) {
r := &booksRoutes{shelf: shelf, l: l}

h := handler.Group("/books")
h.Use(authDeviceMiddleware(a, l))
{
h.GET("", r.listBooks)
}
}

// BookResponse represents a single book in the API response.
type BookResponse struct {
ID string `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
Description string `json:"description,omitempty"`
Publisher string `json:"publisher,omitempty"`
Year int `json:"year,omitempty"`
Series string `json:"series,omitempty"`
SeriesIndex *string `json:"series_index,omitempty"`
ISBN string `json:"isbn,omitempty"`
Format string `json:"format"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}

// BooksListResponse represents the paginated books list API response.
type BooksListResponse struct {
Books []BookResponse `json:"books"`
TotalPages int `json:"total_pages"`
CurrentPage int `json:"current_page"`
HasNext bool `json:"has_next"`
HasPrev bool `json:"has_prev"`
}

// listBooks handles GET /api/v1/books
// Query parameters:
// - search: search string (optional)
// - sort: field to sort by (title, author, series, created_at)
// - order: asc or desc (default: asc)
// - page: page number (default: 1)
// - limit: items per page (default: 50)
func (r *booksRoutes) listBooks(c *gin.Context) {
// Parse pagination parameters
page := 1
if pageStr := c.Query("page"); pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
}

limit := 50
if limitStr := c.Query("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}

// Parse search query parameters
searchQuery := c.Query("search")
sortBy := c.Query("sort")
sortOrder := c.Query("order")

// Default sort order
if sortOrder == "" {
sortOrder = "asc"
}

query := entity.SearchQuery{
Search: searchQuery,
SortBy: sortBy,
SortOrder: sortOrder,
Page: page,
Limit: limit,
}

books, err := r.shelf.ListBooks(c.Request.Context(), query)
if err != nil {
r.l.Error(err, "failed to list books")
errorResponse(c, http.StatusInternalServerError, "failed to list books")
return
}

// Convert to response format
bookResponses := make([]BookResponse, len(books.Books))
for i, book := range books.Books {
var seriesIndex *string
if book.SeriesIndex != nil && book.SeriesIndex.Valid {
val := book.SeriesIndex.Decimal.String()
seriesIndex = &val
}

bookResponses[i] = BookResponse{
ID: book.ID,
Title: book.Title,
Author: book.Author,
Description: book.Description,
Publisher: book.Publisher,
Year: book.Year,
Series: book.Series,
SeriesIndex: seriesIndex,
ISBN: book.ISBN,
Format: book.Format,
CreatedAt: book.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: book.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}

response := BooksListResponse{
Books: bookResponses,
TotalPages: books.TotalPages(),
CurrentPage: page,
HasNext: books.HasNext(),
HasPrev: books.HasPrev(),
}

c.JSON(http.StatusOK, response)
}
4 changes: 4 additions & 0 deletions internal/controller/http/v1/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,8 @@ func NewRouter(handler *gin.Engine, l logger.Interface, a auth.AuthInterface, p
syncRoutes := handler.Group("/syncs")
syncRoutes.Use(authDeviceMiddleware(a, l))
newSyncRoutes(syncRoutes, p, l)

// API v1 routes
apiV1 := handler.Group("/api/v1")
newBooksRoutes(apiV1, shelf, a, l)
}
45 changes: 42 additions & 3 deletions internal/controller/http/web/books.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package web

import (
"fmt"
"net/url"
"os"
"strconv"

Expand Down Expand Up @@ -32,15 +33,37 @@ func newBooksRoutes(handler *gin.RouterGroup, shelf library.Shelf, stats stats.R
}

func (r *booksRoutes) listBooks(c *gin.Context) {
// Parse pagination parameters
page := 1
perPage := 12 // Show 12 books per page for grid layout
if pageStr := c.Query("page"); pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
}
perPage := 12 // Show 12 books per page for grid layout

// Parse search query parameters
searchQuery := c.Query("search")
sortBy := c.Query("sort")
sortOrder := c.Query("order")

// Default sort to created_at for web UI (newest first)
if sortBy == "" {
sortBy = "created_at"
}
if sortOrder == "" {
sortOrder = "desc"
}

books, err := r.shelf.ListBooks(c.Request.Context(), "created_at", "desc", page, perPage)
query := entity.SearchQuery{
Search: searchQuery,
SortBy: sortBy,
SortOrder: sortOrder,
Page: page,
Limit: perPage,
}

books, err := r.shelf.ListBooks(c.Request.Context(), query)
if err != nil {
c.HTML(500, "error", passStandartContext(c, gin.H{"error": err.Error()}))
return
Expand All @@ -64,8 +87,24 @@ func (r *booksRoutes) listBooks(c *gin.Context) {
}
}

// Build query string for pagination links (preserves search/sort/order)
queryParams := ""
if searchQuery != "" {
queryParams += "&search=" + url.QueryEscape(searchQuery)
}
if sortBy != "" {
queryParams += "&sort=" + url.QueryEscape(sortBy)
}
if sortOrder != "" {
queryParams += "&order=" + url.QueryEscape(sortOrder)
}

c.HTML(200, "books", passStandartContext(c, gin.H{
"books": booksWithProgress,
"books": booksWithProgress,
"searchQuery": searchQuery,
"sortBy": sortBy,
"sortOrder": sortOrder,
"queryParams": queryParams,
"pagination": gin.H{
"currentPage": page,
"perPage": perPage,
Expand Down
9 changes: 9 additions & 0 deletions internal/entity/book.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ import (

var ErrBookAlreadyExists = errors.New("Book already exists")

// SearchQuery represents search and filter parameters for book queries.
type SearchQuery struct {
Search string // search string for text matching
SortBy string // field to sort by (title, author, series, created_at)
SortOrder string // asc or desc
Page int // page number (1-indexed)
Limit int // items per page
}

// Book represents a book entity in the database.
type Book struct {
ID string // unique identifier for the book
Expand Down
Loading
Loading