Implement real-time collection updates when books are added/removed:
Backend Changes:
- Added connManager to CollectionHandler struct
- Updated constructor to accept ConnectionManager
- Updated all NewCollectionHandler() calls in ebook.go and main.go
- Added WebSocket broadcasts in AddBooks() handler
- Added WebSocket broadcasts in BulkRemoveBooks() handler
- Broadcasts collection_updated events with:
- collection_id: Which collection changed
- action: books_added or books_removed
- book_ids: Array of affected book IDs
- count: Number of books changed
Frontend Changes:
- Added WebSocket connection in collections UI
- connectWebSocket() establishes connection to /ws/sync
- Listens for collection_updated events
- Shows toast notification on collection change
- Auto-reloads page after 1 second to show updated book list
- Auto-reconnect on disconnect (5s delay)
- Error handling for WebSocket failures
WebSocket Event Format:
{
"type": "collection_updated",
"timestamp": "2026-02-01T12:00:00Z",
"data": {
"collection_id": "uuid",
"action": "books_added",
"book_ids": ["uuid1", "uuid2"],
"count": 2
}
}
User Experience:
- When another user adds books to a collection, all connected clients see:
1. Toast notification: "Collection updated: books_added (2 books)"
2. Page auto-refreshes after 1 second
3. Updated book list displays
- Same for book removal
- Works across multiple browser tabs/devices
- No manual refresh needed
Technical Notes:
- Broadcasts to ALL connected WebSocket clients
- Client-side filtering by collection_id
- Existing progress/conflict broadcasts continue to work
- Connection manager handles broadcast distribution
Resolves Limitation #4: Real-time Collection Updates
1354 lines
46 KiB
Go
1354 lines
46 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookmann/internal/database"
|
|
"bookmann/internal/services"
|
|
wsync "bookmann/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)
|
|
}
|
|
|
|
// Ebook notes handlers (backward compatibility using views)
|
|
|
|
// GetEbookNotes handles GET /api/ebooks/:id/notes
|
|
// GetEbookHighlights handles GET /api/ebooks/:id/highlights
|
|
// 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)
|
|
}
|