From eb73e4a9f96076e6110e4068bdc56e4a0db7eef1 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 7 Feb 2026 19:56:52 -0500 Subject: [PATCH] refactor(handlers): Phase 7 - cleanup ebook.go, remove duplicate methods - Remove 24 duplicate media CRUD methods from ebook.go (884 lines removed) - Keep 12 scanner/watch/scheduler methods on Handler - Move request type declarations to media.go: * CreateMediaItemRequest * UpdateMediaItemRequest * CreateMediaNoteRequest * UpdateMediaNoteRequest * CreateMediaHighlightRequest * UpdateMediaHighlightRequest - Remove unused imports from ebook.go (strconv, pgx) - Fix library.go to use MediaHandler.ListMediaItems instead of Handler ebook.go reduced from 1266 lines to 382 lines (70% reduction) Handler now has focused responsibility: scanner and scheduler operations only This completes Phase 7 of the ebook.go refactoring plan. Result: Clean separation of concerns with no duplicate code --- internal/handlers/ebook.go | 883 ------------------------------------- internal/handlers/media.go | 66 +++ internal/router/library.go | 2 +- 3 files changed, 67 insertions(+), 884 deletions(-) diff --git a/internal/handlers/ebook.go b/internal/handlers/ebook.go index 7540374..defb3e9 100644 --- a/internal/handlers/ebook.go +++ b/internal/handlers/ebook.go @@ -7,12 +7,10 @@ import ( "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" ) @@ -382,884 +380,3 @@ func (h *Handler) StartScheduler() { 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) -} diff --git a/internal/handlers/media.go b/internal/handlers/media.go index a1956ea..4472452 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -16,6 +16,72 @@ import ( "github.com/labstack/echo/v4" ) +// 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"` +} + +// 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"` +} + +// 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"` +} + +// 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"` +} + +// 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"` + NoteID string `json:"note_id"` +} + +// 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"` + NoteID string `json:"note_id"` +} + type MediaHandler struct { db *database.Queries worker *services.Worker diff --git a/internal/router/library.go b/internal/router/library.go index 6d56872..0b2e7ca 100644 --- a/internal/router/library.go +++ b/internal/router/library.go @@ -46,7 +46,7 @@ func registerLibraryRoutes(cfg *Config) { adminLibrary.GET("/:id/media-items", func(c echo.Context) error { libraryID := c.Param("id") c.QueryParams().Set("library_id", libraryID) - return h.ListMediaItems(c) + return cfg.MediaHandler.ListMediaItems(c) }) // User library visibility control