package handlers import ( "bookhoard/internal/database" "bookhoard/internal/services" wsync "bookhoard/internal/sync" "context" "fmt" "net/http" "strconv" "sync" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v4" ) const ( maxPaginationLimit = 1000 ) type Handler struct { db *database.Queries scanner *services.EbookScanner worker *services.Worker scheduler *services.Scheduler ctx context.Context cancel context.CancelFunc mu sync.Mutex watchModeCtx context.Context watchModeCancel context.CancelFunc watchingLibraries map[string]bool connManager *wsync.ConnectionManager } func NewHandler(db *database.Queries, connManager *wsync.ConnectionManager) *Handler { ctx, cancel := context.WithCancel(context.Background()) worker := services.NewWorker(3) scheduler := services.NewScheduler(worker, db) watchCtx, watchCancel := context.WithCancel(context.Background()) return &Handler{ db: db, scanner: services.NewEbookScanner(db), worker: worker, scheduler: scheduler, ctx: ctx, cancel: cancel, watchModeCtx: watchCtx, watchModeCancel: watchCancel, watchingLibraries: make(map[string]bool), connManager: connManager, } } // parseDate parses a date string in YYYY-MM-DD format func parseDate(dateStr string) time.Time { if dateStr == "" { return time.Time{} } if t, err := time.Parse("2006-01-02", dateStr); err == nil { return t } return time.Time{} } func SetupRoutes(g *echo.Group, db *database.Queries, connManager *wsync.ConnectionManager) *Handler { h := NewHandler(db, connManager) // Collections API routes collectionHandler := NewCollectionHandler(db, connManager) collections := g.Group("/collections") collections.GET("", collectionHandler.GetCollections) collections.POST("", collectionHandler.CreateCollection) collections.GET("/:id", collectionHandler.GetCollection) collections.PUT("/:id", collectionHandler.UpdateCollection) collections.DELETE("/:id", collectionHandler.DeleteCollection) collections.GET("/:id/books", collectionHandler.GetBookCollections) collections.POST("/:id/books", collectionHandler.AddBooks) collections.DELETE("/:id/books/:bookId", collectionHandler.RemoveBook) collections.POST("/:id/books/bulk-remove", collectionHandler.BulkRemoveBooks) collections.POST("/bulk-add-books", collectionHandler.HandleBulkAddBooks) collections.POST("/test-rules", collectionHandler.TestRules) // Device shelf mapping routes deviceCollections := g.Group("/devices/:id/collections") deviceCollections.GET("", collectionHandler.GetDeviceMappings) deviceCollections.POST("", collectionHandler.CreateDeviceMapping) deviceCollections.PUT("/:collectionId", collectionHandler.UpdateDeviceMapping) deviceCollections.DELETE("/:collectionId", collectionHandler.DeleteDeviceMapping) // Book matching and linking routes g.POST("/sync/books/query", h.QueryBooks) g.POST("/devices/:deviceId/sync/link-book", h.LinkBook) g.GET("/devices/:deviceId/sync/unlinked-books", h.GetUnlinkedBooks) g.GET("/devices/:id/file-aliases", h.GetDeviceFileAliases) g.POST("/devices/:id/file-aliases", h.CreateDeviceFileAlias) g.PUT("/devices/:id/file-aliases/:aliasId", h.UpdateDeviceFileAlias) g.DELETE("/devices/:id/file-aliases/:aliasId", h.DeleteDeviceFileAlias) g.GET("/books/match", h.GetBookMatches) // Media item routes (all authenticated users) g.GET("/media-items", h.ListMediaItems) g.GET("/media-items/filtered", h.ListMediaItemsFiltered) g.GET("/media-items/search", h.SearchMediaItems) g.GET("/media-items/:id", h.GetMediaItem) g.POST("/media-items/:id/rating", h.CreateMediaRating) g.GET("/media-items/:id/rating", h.GetMediaRating) g.PUT("/media-items/:id/rating", h.UpdateMediaRating) g.DELETE("/media-items/:id/rating", h.DeleteMediaRating) // Legacy progress routes (deprecated - use universal progress instead) g.GET("/media-items/:id/progress", h.GetMediaReadingProgress) g.PUT("/media-items/:id/progress", h.UpdateMediaReadingProgress) g.DELETE("/media-items/:id/progress", h.DeleteMediaReadingProgress) // Universal Progress routes (Phase 1) g.GET("/progress/:id", h.GetUniversalProgress) g.POST("/progress/:id", h.UpdateUniversalProgress) g.GET("/progress/:id/history", h.GetProgressHistory) // Notes routes g.GET("/media-items/:id/notes", h.GetMediaNotes) g.POST("/media-items/:id/notes", h.CreateMediaNote) g.GET("/media-items/:id/notes/:noteId", h.GetMediaNote) g.PUT("/media-items/:id/notes/:noteId", h.UpdateMediaNote) g.DELETE("/media-items/:id/notes/:noteId", h.DeleteMediaNote) // Highlights routes g.GET("/media-items/:id/highlights", h.GetMediaHighlights) g.POST("/media-items/:id/highlights", h.CreateMediaHighlight) g.GET("/media-items/:id/highlights/:highlightId", h.GetMediaHighlight) g.PUT("/media-items/:id/highlights/:highlightId", h.UpdateMediaHighlight) g.DELETE("/media-items/:id/highlights/:highlightId", h.DeleteMediaHighlight) // Admin-only routes admin := g.Group("", AdminMiddleware) admin.POST("/media-items", h.CreateMediaItem) admin.PUT("/media-items/:id", h.UpdateMediaItem) admin.DELETE("/media-items/:id", h.DeleteMediaItem) // Scanner routes (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) }