From 69872b48b57159f9adda44269005a342bbb160b0 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 10 May 2026 11:52:04 -0400 Subject: [PATCH] fix(handlers): wire all 37 fields in UpdateMediaItem, add cover upload support UpdateMediaItem handler: - Add form: tags to UpdateMediaItemRequest for dual JSON/multipart binding - Add 8 missing fields (Language, Edition, PageCount, Genre, CopyrightYear, GoodreadsID, OpenlibraryID, GoogleBooksID) - Add CoverAction field (keep/upload/remove) with multipart cover handling - Fetch existing record before update to preserve cover_image_path when cover_action is "keep" (was clearing cover on every JSON save) - Add saveCoverImage() method: validates image type, resolves library path, saves as {file_path}.cover.jpg - Add HX-Redirect response header for HTMX clients HandleBulkUpdate: - Copy all 37 fields from existingMedia (was missing GoogleBooksID + 14 new fields), preventing data loss on bulk metadata updates. --- internal/handlers/media.go | 187 +++++++++++++++++++++++++++++++------ 1 file changed, 157 insertions(+), 30 deletions(-) diff --git a/internal/handlers/media.go b/internal/handlers/media.go index aaf9e05..54b5ef9 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -12,6 +12,7 @@ import ( "io" "log" "mime" + "mime/multipart" "net/http" "net/url" "os" @@ -62,33 +63,41 @@ type CreateMediaItemRequest struct { // 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"` - // NEW: Reading direction and comic metadata fields - MangaType string `json:"manga_type"` // 'unknown' | 'no' | 'yes' | 'yes_and_right_to_left' - ReadingDirection string `json:"reading_direction"` // 'auto' | 'ltr' | 'rtl' | 'vertical' - SeriesCount int32 `json:"series_count"` - Volume int32 `json:"volume"` - Imprint string `json:"imprint"` - AgeRating string `json:"age_rating"` // 'Everyone' | 'Teen' | 'Mature' | 'Adult' - WebURL string `json:"web_url"` - MetadataNotes string `json:"metadata_notes"` - CommunityRating float64 `json:"community_rating"` - StoryArc string `json:"story_arc"` - IsBlackAndWhite bool `json:"is_black_and_white"` - AlternateInfo string `json:"alternate_info"` // JSON string - ScanInformation string `json:"scan_information"` - Summary string `json:"summary"` + Title string `form:"title" json:"title" validate:"required,min=1,max=500"` + Author string `form:"author" json:"author"` + ISBN string `form:"isbn" json:"isbn"` + Description string `form:"description" json:"description"` + CoverImagePath string `form:"cover_image_path" json:"cover_image_path"` + CoverAction string `form:"cover_action" json:"cover_action"` + Series string `form:"series" json:"series"` + SeriesNumber int32 `form:"series_number" json:"series_number"` + Tags []string `form:"tags" json:"tags"` + ASIN string `form:"asin" json:"asin"` + DatePublished string `form:"date_published" json:"date_published"` + Publisher string `form:"publisher" json:"publisher"` + Contributors []string `form:"contributors" json:"contributors"` + Language string `form:"language" json:"language"` + Edition string `form:"edition" json:"edition"` + PageCount int32 `form:"page_count" json:"page_count"` + Genre string `form:"genre" json:"genre"` + CopyrightYear int32 `form:"copyright_year" json:"copyright_year"` + GoodreadsID string `form:"goodreads_id" json:"goodreads_id"` + OpenlibraryID string `form:"openlibrary_id" json:"openlibrary_id"` + GoogleBooksID string `form:"google_books_id" json:"google_books_id"` + MangaType string `form:"manga_type" json:"manga_type"` + ReadingDirection string `form:"reading_direction" json:"reading_direction"` + SeriesCount int32 `form:"series_count" json:"series_count"` + Volume int32 `form:"volume" json:"volume"` + Imprint string `form:"imprint" json:"imprint"` + AgeRating string `form:"age_rating" json:"age_rating"` + WebURL string `form:"web_url" json:"web_url"` + MetadataNotes string `form:"metadata_notes" json:"metadata_notes"` + CommunityRating float64 `form:"community_rating" json:"community_rating"` + StoryArc string `form:"story_arc" json:"story_arc"` + IsBlackAndWhite bool `form:"is_black_and_white" json:"is_black_and_white"` + AlternateInfo string `form:"alternate_info" json:"alternate_info"` + ScanInformation string `form:"scan_information" json:"scan_information"` + Summary string `form:"summary" json:"summary"` } // CreateMediaNoteRequest represents the request for creating a media note @@ -555,7 +564,22 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error { PageCount: existingMedia.PageCount, GoodreadsID: existingMedia.GoodreadsID, OpenlibraryID: existingMedia.OpenlibraryID, + GoogleBooksID: existingMedia.GoogleBooksID, CoverImagePath: existingMedia.CoverImagePath, + MangaType: existingMedia.MangaType, + ReadingDirection: existingMedia.ReadingDirection, + SeriesCount: existingMedia.SeriesCount, + Volume: existingMedia.Volume, + Imprint: existingMedia.Imprint, + AgeRating: existingMedia.AgeRating, + WebUrl: existingMedia.WebUrl, + MetadataNotes: existingMedia.MetadataNotes, + CommunityRating: existingMedia.CommunityRating, + StoryArc: existingMedia.StoryArc, + IsBlackAndWhite: existingMedia.IsBlackAndWhite, + AlternateInfo: existingMedia.AlternateInfo, + ScanInformation: existingMedia.ScanInformation, + Summary: existingMedia.Summary, } if update.Updates.Title != nil { @@ -1194,25 +1218,52 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error { tagsSearch := utils.NormalizeTagsSearch(req.Tags) contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors) - // Validate and normalize ISBN normalizedISBN, err := utils.NormalizeISBN(req.ISBN) if err != nil && req.ISBN != "" { return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"}) } - // Use normalized ISBN if valid, otherwise empty string isbnValue := normalizedISBN if err != nil { isbnValue = "" } + if req.CoverAction == "" { + req.CoverAction = "keep" + } + + existing, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"}) + } + + coverPath := existing.CoverImagePath.String + + if req.CoverAction == "remove" { + coverPath = "" + } else if req.CoverAction == "upload" { + file, err := c.FormFile("cover_file") + if err == nil { + savedPath, err := mh.saveCoverImage(*c, mediaUUID, file) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save cover image"}) + } + coverPath = savedPath + } + } + + var alternateInfoBytes []byte + if req.AlternateInfo != "" { + alternateInfoBytes = []byte(req.AlternateInfo) + } + item, err := mh.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: isbnValue, Valid: req.ISBN != ""}, Description: pgtype.Text{String: req.Description, Valid: req.Description != ""}, - CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""}, + CoverImagePath: pgtype.Text{String: coverPath, Valid: coverPath != ""}, Series: pgtype.Text{String: req.Series, Valid: req.Series != ""}, SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0}, Tags: req.Tags, @@ -1222,11 +1273,37 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error { Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""}, Contributors: req.Contributors, ContributorsSearch: contributorsSearch, + Language: pgtype.Text{String: req.Language, Valid: req.Language != ""}, + Edition: pgtype.Text{String: req.Edition, Valid: req.Edition != ""}, + PageCount: pgtype.Int4{Int32: req.PageCount, Valid: req.PageCount > 0}, + Genre: pgtype.Text{String: req.Genre, Valid: req.Genre != ""}, + CopyrightYear: pgtype.Int4{Int32: req.CopyrightYear, Valid: req.CopyrightYear > 0}, + GoodreadsID: pgtype.Text{String: req.GoodreadsID, Valid: req.GoodreadsID != ""}, + OpenlibraryID: pgtype.Text{String: req.OpenlibraryID, Valid: req.OpenlibraryID != ""}, + GoogleBooksID: pgtype.Text{String: req.GoogleBooksID, Valid: req.GoogleBooksID != ""}, + MangaType: pgtype.Text{String: req.MangaType, Valid: req.MangaType != ""}, + ReadingDirection: pgtype.Text{String: req.ReadingDirection, Valid: req.ReadingDirection != ""}, + SeriesCount: pgtype.Int4{Int32: req.SeriesCount, Valid: req.SeriesCount > 0}, + Volume: pgtype.Int4{Int32: req.Volume, Valid: req.Volume > 0}, + Imprint: pgtype.Text{String: req.Imprint, Valid: req.Imprint != ""}, + AgeRating: pgtype.Text{String: req.AgeRating, Valid: req.AgeRating != ""}, + WebUrl: pgtype.Text{String: req.WebURL, Valid: req.WebURL != ""}, + MetadataNotes: pgtype.Text{String: req.MetadataNotes, Valid: req.MetadataNotes != ""}, + CommunityRating: pgtype.Float8{Float64: req.CommunityRating, Valid: req.CommunityRating > 0}, + StoryArc: pgtype.Text{String: req.StoryArc, Valid: req.StoryArc != ""}, + IsBlackAndWhite: pgtype.Bool{Bool: req.IsBlackAndWhite, Valid: req.IsBlackAndWhite}, + AlternateInfo: alternateInfoBytes, + ScanInformation: pgtype.Text{String: req.ScanInformation, Valid: req.ScanInformation != ""}, + Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } + if c.Request().Header.Get("HX-Request") == "true" { + c.Response().Header().Set("HX-Redirect", "/media/"+mediaID) + } + return c.JSON(http.StatusOK, item) } @@ -1769,3 +1846,53 @@ func jsonBytesToMap(b []byte) map[string]interface{} { } return result } + +func (mh *MediaHandler) saveCoverImage(c echo.Context, mediaUUID uuid.UUID, file *multipart.FileHeader) (string, error) { + src, err := file.Open() + if err != nil { + return "", fmt.Errorf("failed to open uploaded file: %w", err) + } + defer src.Close() + + imageData, err := io.ReadAll(src) + if err != nil { + return "", fmt.Errorf("failed to read uploaded file: %w", err) + } + + if len(imageData) < 512 { + return "", fmt.Errorf("file too small to be a valid image") + } + + contentType := http.DetectContentType(imageData) + if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" { + return "", fmt.Errorf("invalid image type: %s", contentType) + } + + mediaItem, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}) + if err != nil { + return "", fmt.Errorf("media item not found: %w", err) + } + + relativeFilePath := mediaItem.FilePath + if relativeFilePath == "" { + return "", fmt.Errorf("media item has no file path") + } + + coverRelPath := relativeFilePath + ".cover.jpg" + + coverFullPath, err := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, coverRelPath) + if err != nil { + return "", fmt.Errorf("failed to resolve cover path: %w", err) + } + + coverDir := filepath.Dir(coverFullPath) + if err := os.MkdirAll(coverDir, 0755); err != nil { + return "", fmt.Errorf("failed to create cover directory: %w", err) + } + + if err := os.WriteFile(coverFullPath, imageData, 0644); err != nil { + return "", fmt.Errorf("failed to write cover file: %w", err) + } + + return coverRelPath, nil +}