Files
bookhoard/internal/handlers/ebook.go
T
john-okeefe 00a083b60b Rename backend code references: Bookmann → Bookhoard
Backend changes:
- Update import paths: bookmann/internal → bookhoard/internal
- Rename struct fields: BookmannUUID → BookhoardUUID
- Update handler function names: mapContentIdToBookmannUUID → mapContentIdToBookhoardUUID
- Update HTTP response headers: X-Bookmann-* → X-Bookhoard-*
- Update service and middleware references
- Update main.go imports and references

This is part 2 of the project rename to Bookhoard.
2026-02-01 16:11:54 -05:00

1350 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("/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 (admin only)
admin.POST("/scanner/scan", h.ScanEbooks)
admin.POST("/scanner/start", h.StartScanner)
admin.POST("/scanner/stop", h.StopScanner)
admin.GET("/scanner/status/:jobId", h.GetScanStatus)
// Watch mode routes (admin only)
admin.POST("/scanner/watch/start", h.StartWatchMode)
admin.POST("/scanner/watch/stop", h.StopWatchMode)
admin.GET("/scanner/watch/status", h.GetWatchModeStatus)
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)
}