Files
bookhoard/internal/handlers/ebook.go
T
john-okeefe 46a03ebfda refactor(router): organize scanner routes into dedicated file
Phase 4 of code organization plan

Changes:
- Create internal/router/scanner.go with registerScannerRoutes()
- Move scanner route registration from handlers to router package
- Update internal/router/router.go to call registerScannerRoutes
- Remove inline scanner routes from internal/handlers/ebook.go

Scanner routes now centralized in router/scanner.go:
- POST /scanner/scan - Scan ebooks
- POST /scanner/start - Start scanner
- POST /scanner/stop - Stop scanner
- GET /scanner/status/:jobId - Get scan status
- POST /scanner/watch/start - Start watch mode
- POST /scanner/watch/stop - Stop watch mode
- GET /scanner/watch/status - Get watch mode status

This improves code organization by separating route registration
from handler logic, making the codebase easier to maintain and
follows the established pattern of organizing routes by feature.
2026-02-07 17:08:00 -05:00

1342 lines
46 KiB
Go

package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
wsync "bookhoard/internal/sync"
"context"
"fmt"
"net/http"
"strconv"
"sync"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
)
const (
maxPaginationLimit = 1000
)
type Handler struct {
db *database.Queries
scanner *services.EbookScanner
worker *services.Worker
scheduler *services.Scheduler
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
watchModeCtx context.Context
watchModeCancel context.CancelFunc
watchingLibraries map[string]bool
connManager *wsync.ConnectionManager
}
func NewHandler(db *database.Queries, connManager *wsync.ConnectionManager) *Handler {
ctx, cancel := context.WithCancel(context.Background())
worker := services.NewWorker(3)
scheduler := services.NewScheduler(worker, db)
watchCtx, watchCancel := context.WithCancel(context.Background())
return &Handler{
db: db,
scanner: services.NewEbookScanner(db),
worker: worker,
scheduler: scheduler,
ctx: ctx,
cancel: cancel,
watchModeCtx: watchCtx,
watchModeCancel: watchCancel,
watchingLibraries: make(map[string]bool),
connManager: connManager,
}
}
// parseDate parses a date string in YYYY-MM-DD format
func parseDate(dateStr string) time.Time {
if dateStr == "" {
return time.Time{}
}
if t, err := time.Parse("2006-01-02", dateStr); err == nil {
return t
}
return time.Time{}
}
func SetupRoutes(g *echo.Group, db *database.Queries, connManager *wsync.ConnectionManager) *Handler {
h := NewHandler(db, connManager)
// Collections API routes
collectionHandler := NewCollectionHandler(db, connManager)
collections := g.Group("/collections")
collections.GET("", collectionHandler.GetCollections)
collections.POST("", collectionHandler.CreateCollection)
collections.GET("/:id", collectionHandler.GetCollection)
collections.PUT("/:id", collectionHandler.UpdateCollection)
collections.DELETE("/:id", collectionHandler.DeleteCollection)
collections.GET("/:id/books", collectionHandler.GetBookCollections)
collections.POST("/:id/books", collectionHandler.AddBooks)
collections.DELETE("/:id/books/:bookId", collectionHandler.RemoveBook)
collections.POST("/:id/books/bulk-remove", collectionHandler.BulkRemoveBooks)
collections.POST("/bulk-add-books", collectionHandler.HandleBulkAddBooks)
collections.POST("/test-rules", collectionHandler.TestRules)
// Device shelf mapping routes
deviceCollections := g.Group("/devices/:id/collections")
deviceCollections.GET("", collectionHandler.GetDeviceMappings)
deviceCollections.POST("", collectionHandler.CreateDeviceMapping)
deviceCollections.PUT("/:collectionId", collectionHandler.UpdateDeviceMapping)
deviceCollections.DELETE("/:collectionId", collectionHandler.DeleteDeviceMapping)
// Book matching and linking routes
g.POST("/sync/books/query", h.QueryBooks)
g.POST("/devices/:deviceId/sync/link-book", h.LinkBook)
g.GET("/devices/:deviceId/sync/unlinked-books", h.GetUnlinkedBooks)
g.GET("/devices/:id/file-aliases", h.GetDeviceFileAliases)
g.POST("/devices/:id/file-aliases", h.CreateDeviceFileAlias)
g.PUT("/devices/:id/file-aliases/:aliasId", h.UpdateDeviceFileAlias)
g.DELETE("/devices/:id/file-aliases/:aliasId", h.DeleteDeviceFileAlias)
g.GET("/books/match", h.GetBookMatches)
// Media item routes (all authenticated users)
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)
g.GET("/media-items/:id/rating", h.GetMediaRating)
g.PUT("/media-items/:id/rating", h.UpdateMediaRating)
g.DELETE("/media-items/:id/rating", h.DeleteMediaRating)
// Legacy progress routes (deprecated - use universal progress instead)
g.GET("/media-items/:id/progress", h.GetMediaReadingProgress)
g.PUT("/media-items/:id/progress", h.UpdateMediaReadingProgress)
g.DELETE("/media-items/:id/progress", h.DeleteMediaReadingProgress)
// Universal Progress routes (Phase 1)
g.GET("/progress/:id", h.GetUniversalProgress)
g.POST("/progress/:id", h.UpdateUniversalProgress)
g.GET("/progress/:id/history", h.GetProgressHistory)
// Notes routes
g.GET("/media-items/:id/notes", h.GetMediaNotes)
g.POST("/media-items/:id/notes", h.CreateMediaNote)
g.GET("/media-items/:id/notes/:noteId", h.GetMediaNote)
g.PUT("/media-items/:id/notes/:noteId", h.UpdateMediaNote)
g.DELETE("/media-items/:id/notes/:noteId", h.DeleteMediaNote)
// Highlights routes
g.GET("/media-items/:id/highlights", h.GetMediaHighlights)
g.POST("/media-items/:id/highlights", h.CreateMediaHighlight)
g.GET("/media-items/:id/highlights/:highlightId", h.GetMediaHighlight)
g.PUT("/media-items/:id/highlights/:highlightId", h.UpdateMediaHighlight)
g.DELETE("/media-items/:id/highlights/:highlightId", h.DeleteMediaHighlight)
// Admin-only routes
admin := g.Group("", AdminMiddleware)
admin.POST("/media-items", h.CreateMediaItem)
admin.PUT("/media-items/:id", h.UpdateMediaItem)
admin.DELETE("/media-items/:id", h.DeleteMediaItem)
// Scanner routes are registered in router/scanner.go
return h
}
// ScanEbooksRequest represents the request for scanning ebooks
type ScanEbooksRequest struct {
FolderPaths []string `json:"folder_paths,omitempty"`
LibraryID string `json:"library_id,omitempty"`
}
// ScanEbooks handles POST /api/scanner/scan (now runs in background)
func (h *Handler) ScanEbooks(c echo.Context) error {
var req ScanEbooksRequest
// Check if scan_request is set in context (from library scan route)
if scanReq, ok := c.Get("scan_request").(map[string]interface{}); ok {
if libraryID, ok := scanReq["library_id"].(string); ok {
req.LibraryID = libraryID
}
}
// Bind request body if provided (for direct scanner/scan calls)
if err := c.Bind(&req); err != nil && req.LibraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
// Get user ID from JWT token
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
var folderPaths []string
// If library_id is provided, fetch folders from library
if req.LibraryID != "" {
libraryUUID, err := uuid.Parse(req.LibraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
}
// Fetch library folders from database
libraryFolders, err := h.db.GetLibraryFolders(c.Request().Context(), pgtype.UUID{Bytes: [16]byte(libraryUUID), Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "library not found or has no folders"})
}
// Extract folder paths
for _, folder := range libraryFolders {
folderPaths = append(folderPaths, folder.FolderPath)
}
if len(folderPaths) == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library has no folders configured"})
}
} else if len(req.FolderPaths) > 0 {
// Use folder paths from request
folderPaths = req.FolderPaths
} else {
// Neither library_id nor folder_paths provided
return c.JSON(http.StatusBadRequest, map[string]string{"error": "either library_id or folder_paths required for scanning"})
}
jobID := uuid.New().String()
job := &services.Job{
ID: jobID,
Type: services.JobTypeScan,
Params: map[string]interface{}{
"library_id": req.LibraryID,
"folders": folderPaths,
"admin_id": userUUID.String(),
"db": h.db,
},
Status: services.JobStatusPending,
Context: h.ctx,
}
if err := h.worker.EnqueueJob(job); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to enqueue scan job: " + err.Error()})
}
return c.JSON(http.StatusAccepted, map[string]interface{}{
"message": "scan job enqueued",
"job_id": jobID,
"status": "pending",
})
}
// StartScanner handles POST /api/scanner/start
func (h *Handler) StartScanner(c echo.Context) error {
h.mu.Lock()
defer h.mu.Unlock()
var req ScanEbooksRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
// Get admin ID from JWT token
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
// Set the folder paths
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
}
// Set the admin ID for ebook association
h.scanner.SetAdminID(pgtype.UUID{Bytes: userUUID, Valid: true})
// Start watching for changes
h.scanner.WatchChanges(h.ctx)
return c.JSON(http.StatusOK, map[string]string{"message": "scanner started"})
}
// StopScanner handles POST /api/scanner/stop
func (h *Handler) StopScanner(c echo.Context) error {
h.mu.Lock()
defer h.mu.Unlock()
h.cancel()
h.ctx, h.cancel = context.WithCancel(context.Background())
return c.JSON(http.StatusOK, map[string]string{"message": "scanner stopped"})
}
// GetScanStatus handles GET /api/scanner/status/:jobId
func (h *Handler) GetScanStatus(c echo.Context) error {
jobID := c.Param("jobId")
result, exists := h.worker.GetJobStatus(jobID)
if !exists {
return c.JSON(http.StatusNotFound, map[string]string{"error": "job not found"})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"job_id": result.JobID,
"status": result.Status,
"error": result.Error,
"result": result.Result,
"progress": result.Progress,
})
}
// StartWatchModeForLibrary starts watching a specific library's folders
func (h *Handler) StartWatchModeForLibrary(ctx context.Context, libraryID pgtype.UUID, adminID pgtype.UUID) error {
h.mu.Lock()
defer h.mu.Unlock()
libraryIDStr := fmt.Sprintf("%x", libraryID.Bytes)
if h.watchingLibraries[libraryIDStr] {
return fmt.Errorf("already watching library %s", libraryIDStr)
}
folders, err := h.db.GetLibraryFolders(ctx, libraryID)
if err != nil {
return fmt.Errorf("failed to get library folders: %v", err)
}
if len(folders) == 0 {
return fmt.Errorf("no folders configured for library")
}
folderPaths := make([]string, len(folders))
for i, folder := range folders {
folderPaths[i] = folder.FolderPath
}
scanner := services.NewEbookScanner(h.db)
if err := scanner.SetFolders(folderPaths); err != nil {
return fmt.Errorf("failed to set scanner folders: %v", err)
}
scanner.SetAdminID(adminID)
scanner.WatchChanges(h.watchModeCtx)
h.watchingLibraries[libraryIDStr] = true
return nil
}
// StopWatchModeForLibrary stops watching a specific library
func (h *Handler) StopWatchModeForLibrary(libraryID pgtype.UUID) error {
h.mu.Lock()
defer h.mu.Unlock()
libraryIDStr := fmt.Sprintf("%x", libraryID.Bytes)
if !h.watchingLibraries[libraryIDStr] {
return fmt.Errorf("not watching library %s", libraryIDStr)
}
delete(h.watchingLibraries, libraryIDStr)
if len(h.watchingLibraries) == 0 {
h.watchModeCancel()
newCtx, newCancel := context.WithCancel(context.Background())
h.watchModeCtx = newCtx
h.watchModeCancel = newCancel
}
return nil
}
// StartWatchMode handles POST /api/scanner/watch/start
func (h *Handler) StartWatchMode(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
var req struct {
LibraryID string `json:"library_id"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if req.LibraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
}
libraryID, err := uuid.Parse(req.LibraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
}
if err := h.StartWatchModeForLibrary(c.Request().Context(), pgtype.UUID{Bytes: [16]byte(libraryID), Valid: true}, pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "watch mode started for library",
"library_id": req.LibraryID,
})
}
// StopWatchMode handles POST /api/scanner/watch/stop
func (h *Handler) StopWatchMode(c echo.Context) error {
var req struct {
LibraryID string `json:"library_id"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if req.LibraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
}
libraryID, err := uuid.Parse(req.LibraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
}
if err := h.StopWatchModeForLibrary(pgtype.UUID{Bytes: [16]byte(libraryID), Valid: true}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "watch mode stopped for library",
"library_id": req.LibraryID,
})
}
// GetWatchModeStatus handles GET /api/scanner/watch/status
func (h *Handler) GetWatchModeStatus(c echo.Context) error {
h.mu.Lock()
defer h.mu.Unlock()
watchingLibraries := make([]string, 0, len(h.watchingLibraries))
for libID := range h.watchingLibraries {
watchingLibraries = append(watchingLibraries, libID)
}
return c.JSON(http.StatusOK, map[string]interface{}{
"watching_libraries": watchingLibraries,
"total_watching": len(watchingLibraries),
})
}
// StartWatchModeForAllLibraries starts watching all configured libraries
func (h *Handler) StartWatchModeForAllLibraries(ctx context.Context) error {
libraries, err := h.db.ListLibraries(ctx)
if err != nil {
return fmt.Errorf("failed to list libraries: %v", err)
}
for _, library := range libraries {
libraryIDStr := fmt.Sprintf("%x", library.ID.Bytes)
if err := h.StartWatchModeForLibrary(ctx, library.ID, library.ID); err != nil {
fmt.Printf("Warning: failed to start watch mode for library %s: %v\n", libraryIDStr, err)
continue
}
fmt.Printf("Started watch mode for library %s (%s)\n", library.Name, libraryIDStr)
}
return nil
}
// StartScheduler starts the auto-scan scheduler
func (h *Handler) StartScheduler() {
h.scheduler.Start()
}
// StopScheduler stops the auto-scan scheduler
func (h *Handler) StopScheduler() {
h.scheduler.Stop()
}
// Media Item handlers for new library system
// 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"))
if limit == 0 {
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
}
if offset < 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "offset cannot be negative"})
}
if libraryID != "" {
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
}
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()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"data": items})
}
items, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
Limit: int32(limit),
Offset: int32(offset),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"data": items})
}
// GetMediaItem handles GET /api/media-items/:id
func (h *Handler) GetMediaItem(c echo.Context) error {
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
item, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.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)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
var req struct {
Rating int32 `json:"rating" validate:"required,min=1,max=10"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
rating, err := h.db.CreateMediaRating(c.Request().Context(), database.CreateMediaRatingParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Rating: req.Rating,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, rating)
}
// GetMediaRating handles GET /api/media-items/:id/rating
func (h *Handler) GetMediaRating(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
rating, err := h.db.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusOK, map[string]interface{}{"rating": nil})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, rating)
}
// UpdateMediaRating handles PUT /api/media-items/:id/rating
func (h *Handler) UpdateMediaRating(c echo.Context) error {
return h.CreateMediaRating(c) // Same logic as create due to upsert
}
// DeleteMediaRating handles DELETE /api/media-items/:id/rating
func (h *Handler) DeleteMediaRating(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
err = h.db.DeleteMediaRating(c.Request().Context(), database.DeleteMediaRatingParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "rating deleted"})
}
// GetMediaReadingProgress handles GET /api/media-items/:id/progress
func (h *Handler) GetMediaReadingProgress(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
progress, err := h.db.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusOK, map[string]interface{}{
"current_page": 0,
"total_pages": nil,
})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, progress)
}
// UpdateMediaReadingProgress handles PUT /api/media-items/:id/progress
func (h *Handler) UpdateMediaReadingProgress(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
var req struct {
CurrentPage int32 `json:"current_page"`
TotalPages int32 `json:"total_pages"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
progress, err := h.db.UpdateReadingProgress(c.Request().Context(), database.UpdateReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
CurrentPage: pgtype.Int4{Int32: req.CurrentPage, Valid: true},
TotalPages: pgtype.Int4{Int32: req.TotalPages, Valid: req.TotalPages > 0},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, progress)
}
// DeleteMediaReadingProgress handles DELETE /api/media-items/:id/progress
func (h *Handler) DeleteMediaReadingProgress(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
err = h.db.DeleteReadingProgress(c.Request().Context(), database.DeleteReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "reading progress deleted"})
}
// Admin Media Items handlers
// CreateMediaItemRequest represents the request for creating a media item
type CreateMediaItemRequest struct {
LibraryID uuid.UUID `json:"library_id" validate:"required"`
Title string `json:"title" validate:"required,min=1,max=500"`
Author string `json:"author"`
ISBN string `json:"isbn"`
Description string `json:"description"`
FilePath string `json:"file_path" validate:"required"`
FileSize int64 `json:"file_size" validate:"required,min=1"`
MimeType string `json:"mime_type" validate:"required"`
CoverImagePath string `json:"cover_image_path"`
Series string `json:"series"`
SeriesNumber int32 `json:"series_number"`
Tags string `json:"tags"`
ASIN string `json:"asin"`
DatePublished string `json:"date_published"`
Publisher string `json:"publisher"`
Contributors string `json:"contributors"`
}
// CreateMediaItem handles POST /api/media-items (admin only)
func (h *Handler) CreateMediaItem(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
// Verify user is admin
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
var req CreateMediaItemRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Validate library exists
_, err := h.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
mediaItemID, err := h.db.CreateMediaItem(c.Request().Context(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true},
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
Isbn: pgtype.Text{String: req.ISBN, Valid: req.ISBN != ""},
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
FilePath: req.FilePath,
FileSize: pgtype.Int8{Int64: req.FileSize, Valid: req.FileSize > 0},
MimeType: pgtype.Text{String: req.MimeType, Valid: req.MimeType != ""},
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
Tags: pgtype.Text{String: req.Tags, Valid: req.Tags != ""},
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
Contributors: pgtype.Text{String: req.Contributors, Valid: req.Contributors != ""},
AddedByAdminID: user.ID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// Return the created item info
item, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaItemID.ID.Bytes, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, item)
}
// UpdateMediaItemRequest represents the request for updating a media item
type UpdateMediaItemRequest struct {
Title string `json:"title" validate:"required,min=1,max=500"`
Author string `json:"author"`
ISBN string `json:"isbn"`
Description string `json:"description"`
CoverImagePath string `json:"cover_image_path"`
Series string `json:"series"`
SeriesNumber int32 `json:"series_number"`
Tags string `json:"tags"`
ASIN string `json:"asin"`
DatePublished string `json:"date_published"`
Publisher string `json:"publisher"`
Contributors string `json:"contributors"`
}
// UpdateMediaItem handles PUT /api/media-items/:id (admin only)
func (h *Handler) UpdateMediaItem(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
// Verify user is admin
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
var req UpdateMediaItemRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
item, err := h.db.UpdateMediaItem(c.Request().Context(), database.UpdateMediaItemParams{
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
Isbn: pgtype.Text{String: req.ISBN, Valid: req.ISBN != ""},
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
Tags: pgtype.Text{String: req.Tags, Valid: req.Tags != ""},
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
Contributors: pgtype.Text{String: req.Contributors, Valid: req.Contributors != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, item)
}
// DeleteMediaItem handles DELETE /api/media-items/:id (admin only)
func (h *Handler) DeleteMediaItem(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
// Verify user is admin
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
err = h.db.DeleteMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
// Media Notes handlers
// GetMediaNotes handles GET /api/media-items/:id/notes
func (h *Handler) GetMediaNotes(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
notes, err := h.db.GetMediaNotes(c.Request().Context(), database.GetMediaNotesParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, notes)
}
// CreateMediaNoteRequest represents the request for creating a media note
type CreateMediaNoteRequest struct {
Content string `json:"content" validate:"required,min=1,max=10000"`
Position string `json:"position"`
}
// CreateMediaNote handles POST /api/media-items/:id/notes
func (h *Handler) CreateMediaNote(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
var req CreateMediaNoteRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
note, err := h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Content: req.Content,
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, note)
}
// GetMediaNote handles GET /api/media-items/:id/notes/:noteId
func (h *Handler) GetMediaNote(c echo.Context) error {
noteID := c.Param("noteId")
noteUUID, err := uuid.Parse(noteID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
}
note, err := h.db.GetMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusNotFound, map[string]string{"error": "note not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, note)
}
// UpdateMediaNoteRequest represents the request for updating a media note
type UpdateMediaNoteRequest struct {
Content string `json:"content" validate:"required,min=1,max=10000"`
Position string `json:"position"`
}
// UpdateMediaNote handles PUT /api/media-items/:id/notes/:noteId
func (h *Handler) UpdateMediaNote(c echo.Context) error {
noteID := c.Param("noteId")
noteUUID, err := uuid.Parse(noteID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
}
var req UpdateMediaNoteRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
note, err := h.db.UpdateMediaNote(c.Request().Context(), database.UpdateMediaNoteParams{
ID: pgtype.UUID{Bytes: noteUUID, Valid: true},
Content: req.Content,
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, note)
}
// DeleteMediaNote handles DELETE /api/media-items/:id/notes/:noteId
func (h *Handler) DeleteMediaNote(c echo.Context) error {
noteID := c.Param("noteId")
noteUUID, err := uuid.Parse(noteID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
}
err = h.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
// Media Highlights handlers
// GetMediaHighlights handles GET /api/media-items/:id/highlights
func (h *Handler) GetMediaHighlights(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
highlights, err := h.db.GetMediaHighlights(c.Request().Context(), database.GetMediaHighlightsParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, highlights)
}
// CreateMediaHighlightRequest represents the request for creating a media highlight
type CreateMediaHighlightRequest struct {
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
StartPosition string `json:"start_position" validate:"required,max=100"`
EndPosition string `json:"end_position" validate:"required,max=100"`
Color string `json:"color" validate:"omitempty,len=7"` // hex color
NoteID string `json:"note_id"`
}
// CreateMediaHighlight handles POST /api/media-items/:id/highlights
func (h *Handler) CreateMediaHighlight(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
var req CreateMediaHighlightRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
var noteUUID pgtype.UUID
if req.NoteID != "" {
if noteID, err := uuid.Parse(req.NoteID); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
} else {
noteUUID = pgtype.UUID{Bytes: noteID, Valid: true}
}
}
color := "#ffff00" // default yellow
if req.Color != "" {
color = req.Color
}
highlight, err := h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
SelectionText: req.SelectionText,
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
Color: pgtype.Text{String: color, Valid: true},
NoteID: noteUUID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, highlight)
}
// GetMediaHighlight handles GET /api/media-items/:id/highlights/:highlightId
func (h *Handler) GetMediaHighlight(c echo.Context) error {
highlightID := c.Param("highlightId")
highlightUUID, err := uuid.Parse(highlightID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
}
highlight, err := h.db.GetMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusNotFound, map[string]string{"error": "highlight not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, highlight)
}
// UpdateMediaHighlightRequest represents the request for updating a media highlight
type UpdateMediaHighlightRequest struct {
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
StartPosition string `json:"start_position" validate:"required,max=100"`
EndPosition string `json:"end_position" validate:"required,max=100"`
Color string `json:"color" validate:"omitempty,len=7"` // hex color
NoteID string `json:"note_id"`
}
// UpdateMediaHighlight handles PUT /api/media-items/:id/highlights/:highlightId
func (h *Handler) UpdateMediaHighlight(c echo.Context) error {
highlightID := c.Param("highlightId")
highlightUUID, err := uuid.Parse(highlightID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
}
var req UpdateMediaHighlightRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
var noteUUID pgtype.UUID
if req.NoteID != "" {
if noteID, err := uuid.Parse(req.NoteID); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
} else {
noteUUID = pgtype.UUID{Bytes: noteID, Valid: true}
}
}
color := "#ffff00" // default yellow
if req.Color != "" {
color = req.Color
}
highlight, err := h.db.UpdateMediaHighlight(c.Request().Context(), database.UpdateMediaHighlightParams{
ID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
SelectionText: req.SelectionText,
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
Color: pgtype.Text{String: color, Valid: true},
NoteID: noteUUID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, highlight)
}
// DeleteMediaHighlight handles DELETE /api/media-items/:id/highlights/:highlightId
func (h *Handler) DeleteMediaHighlight(c echo.Context) error {
highlightID := c.Param("highlightId")
highlightUUID, err := uuid.Parse(highlightID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
}
err = h.db.DeleteMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
// SearchMediaItems handles GET /api/media-items/search
// Performs partial matching search with fuzzy fallback if no results found
func (h *Handler) SearchMediaItems(c echo.Context) error {
query := c.QueryParam("q")
userID := c.Get("user_id").(string)
if query == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "query parameter 'q' is required"})
}
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
limit := int32(50)
offset := int32(0)
searchPattern := "%" + query + "%"
partialResults, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
SearchPattern: pgtype.Text{String: searchPattern, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Limit: pgtype.Int4{Int32: limit, Valid: true},
Offset: pgtype.Int4{Int32: offset, Valid: true},
})
if err != nil && err != pgx.ErrNoRows {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if len(partialResults) > 0 {
return c.JSON(http.StatusOK, partialResults)
}
fuzzyResults, err := h.db.SearchMediaItemsFuzzy(c.Request().Context(), database.SearchMediaItemsFuzzyParams{
SearchQuery: pgtype.Text{String: query, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Limit: pgtype.Int4{Int32: limit, Valid: true},
Offset: pgtype.Int4{Int32: offset, Valid: true},
})
if err != nil && err != pgx.ErrNoRows {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if len(fuzzyResults) == 0 {
return c.JSON(http.StatusNotFound, map[string]interface{}{
"error": "no results found",
"query": query,
"results": []interface{}{},
})
}
return c.JSON(http.StatusOK, fuzzyResults)
}